@meistrari/chat-nuxt 4.2.0-rc.4 → 4.2.0-rc.6

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.
package/README.md CHANGED
@@ -35,11 +35,13 @@ Runtime internals under `src/runtime/**`, generated Nuxt aliases, and workspace-
35
35
 
36
36
  ## Conversation forks
37
37
 
38
- Hover a completed message (or focus its actions with the keyboard) and choose **Iniciar nova
39
- conversa a partir daqui** from the three-dot menu inside the top-right of either message bubble. Touch devices show the action without requiring
40
- hover. The embed selects the new conversation and emits its existing `update:conversationId` event.
41
- A user-message cutoff starts a response automatically; an assistant-message cutoff waits for input.
38
+ Completed assistant responses offer **Iniciar nova conversa a partir daqui** in the three-dot
39
+ menu below the response, beside the feedback controls. User messages cannot be forked.
40
+ The embed selects the new conversation and emits its existing `update:conversationId` event.
41
+ The new conversation stays idle until the user sends a message.
42
42
  Fork titles use `<original title> - Cópia <number>` and remain intact when the first response completes.
43
+ New forks show a separator after the copied response with a link to the immediate source conversation.
44
+ Existing forks do not gain this separator.
43
45
 
44
46
  This feature requires the matching Chat API fork endpoint described in
45
47
  [Conversation forks](../chat-api/README.md#conversation-forks). History, tool data and file references
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
3
  "configKey": "chatNuxt",
4
- "version": "4.2.0-rc.4",
4
+ "version": "4.2.0-rc.6",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { useVirtualList } from "@vueuse/core";
2
3
  import { useConversationList } from "../composables/conversation-list";
3
4
  const props = defineProps({
4
5
  conversations: { type: null, required: true },
@@ -48,11 +49,122 @@ const {
48
49
  handleConversationDrop,
49
50
  startConversationInGroup
50
51
  } = useConversationList(props);
52
+ const rows = computed(() => {
53
+ const result = [];
54
+ if (groupItems.value.length > 0)
55
+ result.push({ type: "folders", key: "folders", height: 18, groupId: null });
56
+ for (const groupBucket of groupItems.value) {
57
+ const groupId = groupBucket.group.id;
58
+ result.push({ type: "group", key: `group:${groupId}`, height: 34, groupId, groupBucket });
59
+ if (groupBucket.isRenaming && renameGroupError.value)
60
+ result.push({ type: "error", key: `error:${groupId}`, height: 20, groupId, groupBucket });
61
+ if (!groupBucket.open)
62
+ continue;
63
+ for (const conversation of groupBucket.items)
64
+ result.push({ type: "conversation", key: conversation.id, height: 33, groupId, conversation });
65
+ if (groupBucket.showNewConversationAction)
66
+ result.push({ type: "new", key: `new:${groupId}`, height: 33, groupId, groupBucket });
67
+ else if (groupBucket.showSearchEmpty)
68
+ result.push({ type: "empty", key: `empty:${groupId}`, height: 33, groupId, groupBucket });
69
+ }
70
+ result.push({ type: "ungrouped", key: "ungrouped", height: 24, groupId: null });
71
+ for (const conversation of ungroupedConversations.value)
72
+ result.push({ type: "conversation", key: conversation.id, height: 33, groupId: null, conversation });
73
+ let offset = 0;
74
+ return result.map((row) => {
75
+ const positionedRow = { ...row, offset };
76
+ offset += row.height;
77
+ return positionedRow;
78
+ });
79
+ });
80
+ const { list: visibleRows, containerProps, scrollTo } = useVirtualList(rows, {
81
+ itemHeight: (index) => rows.value[index].height,
82
+ overscan: 8
83
+ });
84
+ const retainedRowKey = ref(null);
85
+ const draggedRowKey = ref(null);
86
+ const renderedRows = computed(() => {
87
+ const indexes = new Set(visibleRows.value.map((row) => row.index));
88
+ for (const key of [retainedRowKey.value, draggedRowKey.value]) {
89
+ const index = rows.value.findIndex((row) => row.key === key);
90
+ if (index >= 0)
91
+ indexes.add(index);
92
+ }
93
+ return [...indexes].sort((a, b) => a - b).map((index) => ({ ...rows.value[index], index }));
94
+ });
95
+ const totalHeight = computed(() => {
96
+ const last = rows.value.at(-1);
97
+ return last ? last.offset + last.height : 0;
98
+ });
99
+ function revealRow(index) {
100
+ const container = containerProps.ref.value;
101
+ const row = rows.value[index];
102
+ if (!row || !container)
103
+ return;
104
+ if (row.offset < container.scrollTop || row.offset + row.height > container.scrollTop + container.clientHeight)
105
+ scrollTo(index);
106
+ }
107
+ const activeRowIndex = computed(() => rows.value.findIndex((row) => row.type === "conversation" && row.conversation.id === props.currentId));
108
+ watch([activeRowIndex, containerProps.ref], ([index]) => revealRow(index), { flush: "post" });
109
+ const renamingGroupRowIndex = computed(() => rows.value.findIndex((row) => row.type === "group" && row.groupBucket.isRenaming));
110
+ watch([renamingGroupRowIndex, containerProps.ref], ([index]) => revealRow(index), { flush: "post" });
111
+ async function handleRowKeydown(event, index) {
112
+ if (event.key !== "Tab" || event.defaultPrevented)
113
+ return;
114
+ const selector = 'a[href], button:not([disabled]), input:not([disabled]), [tabindex="0"]';
115
+ const controls = [...event.currentTarget.querySelectorAll(selector)];
116
+ if (event.target !== (event.shiftKey ? controls[0] : controls.at(-1)))
117
+ return;
118
+ const direction = event.shiftKey ? -1 : 1;
119
+ let nextIndex = index + direction;
120
+ while (rows.value[nextIndex] && ["folders", "ungrouped", "empty", "error"].includes(rows.value[nextIndex].type))
121
+ nextIndex += direction;
122
+ const nextRow = rows.value[nextIndex];
123
+ if (!nextRow)
124
+ return;
125
+ event.preventDefault();
126
+ retainedRowKey.value = nextRow.key;
127
+ revealRow(nextIndex);
128
+ await nextTick();
129
+ const nextControls = containerProps.ref.value?.querySelector(`[data-row-index="${nextIndex}"]`)?.querySelectorAll(selector);
130
+ const target = event.shiftKey ? nextControls?.[nextControls.length - 1] : nextControls?.[0];
131
+ target?.focus();
132
+ }
133
+ watch(conversationSearch, () => scrollTo(0), { flush: "post" });
134
+ function acceptsDrops(row) {
135
+ return row.type !== "folders";
136
+ }
137
+ function dropListeners(row) {
138
+ if (!acceptsDrops(row))
139
+ return {};
140
+ return {
141
+ dragenter: (event) => {
142
+ event.preventDefault();
143
+ handleDragEnter(row.groupId);
144
+ },
145
+ dragover: (event) => {
146
+ event.preventDefault();
147
+ handleDragOver(event, row.groupId);
148
+ },
149
+ dragleave: (event) => handleDragLeave(event, row.groupId),
150
+ drop: (event) => {
151
+ event.preventDefault();
152
+ void handleConversationDrop(event, row.groupId);
153
+ }
154
+ };
155
+ }
156
+ function dropTargetClass(row) {
157
+ if (!acceptsDrops(row))
158
+ return "";
159
+ if (row.groupId)
160
+ return groupItems.value.find((group) => group.group.id === row.groupId)?.dropTarget ? "bg-neutral-200" : "";
161
+ return ungroupedDropTarget.value ? "bg-lowered" : "";
162
+ }
51
163
  </script>
52
164
 
53
165
  <template>
54
- <div flex="~ col" gap-12px>
55
- <div px-8px flex="~ col" gap-8px>
166
+ <div flex="~ col" gap-12px h-full min-h-0 overflow-hidden>
167
+ <div px-8px flex="~ col" gap-8px shrink-0>
56
168
  <ChatConversationSearch v-model="conversationSearch" />
57
169
  <ChatConversationCreatorFilter />
58
170
  </div>
@@ -79,33 +191,39 @@ const {
79
191
  </p>
80
192
  </div>
81
193
 
82
- <div v-else flex="~ col" gap-4px mt-4px>
83
- <div v-if="groupItems.length > 0" flex="~ col" gap-2px>
84
- <span body-12-medium text-secondary pl-10px>
85
- Pastas · {{ groupItems.length }}
86
- </span>
87
-
194
+ <div v-else v-bind="containerProps" flex-1 min-h-0 mt-4px>
195
+ <div relative :style="{ height: `${totalHeight}px` }">
88
196
  <div
89
- v-for="groupBucket in groupItems"
90
- :key="groupBucket.group.id"
91
- flex="~ col"
197
+ v-for="row in renderedRows"
198
+ :key="row.key"
199
+ :style="{ height: `${row.height}px`, top: `${row.offset}px` }"
200
+ absolute inset-x-0
201
+ :data-row-index="row.index"
92
202
  rounded-8px
93
- transition-colors
94
- :class="groupBucket.dropTarget ? 'bg-neutral-200' : ''"
95
- @dragenter.prevent="handleDragEnter(groupBucket.group.id)"
96
- @dragover.prevent="handleDragOver($event, groupBucket.group.id)"
97
- @dragleave="handleDragLeave($event, groupBucket.group.id)"
98
- @drop.prevent="handleConversationDrop($event, groupBucket.group.id)"
203
+ :class="dropTargetClass(row)"
204
+ @keydown="handleRowKeydown($event, row.index)"
205
+ @focusin="retainedRowKey = row.key"
206
+ @pointerdown="retainedRowKey = row.key"
207
+ @dragstart.capture="draggedRowKey = row.key"
208
+ @dragend.capture="draggedRowKey = null"
209
+ v-on="dropListeners(row)"
99
210
  >
211
+ <span v-if="row.type === 'folders'" body-12-medium text-secondary pl-10px>
212
+ Pastas · {{ groupItems.length }}
213
+ </span>
214
+ <span v-else-if="row.type === 'ungrouped'" block body-12-medium text-secondary pl-10px pt-4px>
215
+ {{ ungroupedConversationLabel }}
216
+ </span>
100
217
  <div
218
+ v-if="row.type === 'group'"
101
219
  rounded-10px
102
220
  class="group"
103
- :class="groupBucket.dropTarget ? '' : groupBucket.open ? 'bg-muted' : 'hover:bg-subtle'"
221
+ :class="row.groupBucket.dropTarget ? '' : row.groupBucket.open ? 'bg-muted' : 'hover:bg-subtle'"
104
222
  >
105
223
  <div h-32px w-full pl-10px pr-4px flex items-center gap-8px text-left>
106
- <template v-if="groupBucket.isRenaming">
224
+ <template v-if="row.groupBucket.isRenaming">
107
225
  <TelaIcon
108
- :name="groupBucket.folderIcon"
226
+ :name="row.groupBucket.folderIcon"
109
227
  size="16px"
110
228
  color="icon-secondary"
111
229
  shrink-0
@@ -116,7 +234,7 @@ const {
116
234
  mr-4px
117
235
  >
118
236
  <input
119
- :ref="(element) => setRenameGroupInputRef(element, groupBucket.group.id)"
237
+ :ref="(element) => setRenameGroupInputRef(element, row.groupBucket.group.id)"
120
238
  v-model="renameGroupName"
121
239
  type="text"
122
240
  autocomplete="off"
@@ -148,19 +266,20 @@ const {
148
266
  flex items-center gap-8px
149
267
  text-left
150
268
  cursor-pointer
151
- @click="toggleGroup(groupBucket.group.id)"
269
+ :aria-expanded="row.groupBucket.open"
270
+ @click="toggleGroup(row.groupBucket.group.id)"
152
271
  >
153
272
  <TelaIcon
154
- :name="groupBucket.folderIcon"
273
+ :name="row.groupBucket.folderIcon"
155
274
  size="16px"
156
275
  color="icon-secondary"
157
276
  shrink-0
158
277
  />
159
278
  <div flex items-center gap-6px>
160
- <span min-w-0 truncate body-14-medium :class="groupBucket.open ? 'text-primary' : 'text-secondary group-hover:text-primary'">
161
- {{ groupBucket.group.name }}
279
+ <span min-w-0 truncate body-14-medium :class="row.groupBucket.open ? 'text-primary' : 'text-secondary group-hover:text-primary'">
280
+ {{ row.groupBucket.group.name }}
162
281
  </span>
163
- <TelaAnimatedNumber v-if="groupBucket.conversationCountLabel" :value="groupBucket.conversationCountLabel" body-12-medium text-tertiary />
282
+ <TelaAnimatedNumber v-if="row.groupBucket.conversationCountLabel" :value="row.groupBucket.conversationCountLabel" body-12-medium text-tertiary />
164
283
  </div>
165
284
  </button>
166
285
 
@@ -169,7 +288,7 @@ const {
169
288
  class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 [&:has([data-state=open])]:opacity-100"
170
289
  >
171
290
  <TelaDropdownMenu
172
- :items="groupBucket.menuItems"
291
+ :items="row.groupBucket.menuItems"
173
292
  content-class="chat-sidebar-floating"
174
293
  >
175
294
  <button
@@ -194,82 +313,15 @@ const {
194
313
  </div>
195
314
  </div>
196
315
 
197
- <div
198
- v-if="groupBucket.isRenaming && renameGroupError"
199
- ml-44px mr-8px mb-4px
200
- >
201
- <span text-12px text-red-600 leading-16px aria-live="polite">
316
+ <div v-if="row.type === 'error'" ml-44px mr-8px>
317
+ <span block truncate text-12px text-red-600 leading-16px aria-live="polite" :title="renameGroupError ?? void 0">
202
318
  {{ renameGroupError }}
203
319
  </span>
204
320
  </div>
205
-
206
- <TelaCollapsible :open="groupBucket.open" @update:open="toggleGroup(groupBucket.group.id)">
207
- <TelaCollapsibleContent>
208
- <div flex="~ col" relative>
209
- <div flex="~ col" gap-1px mt-1px>
210
- <ChatConversationItem
211
- v-for="conversation in groupBucket.items"
212
- :key="conversation.id"
213
- v-bind="getConversationItemProps(conversation)"
214
- item-class="ml-24px"
215
- @delete="handleDelete"
216
- @rename="handleRename"
217
- @duplicate="handleDuplicate"
218
- @export="handleExport"
219
- @drag-start="handleConversationDragStart"
220
- @drag-end="clearDragState"
221
- />
222
- <button
223
- v-if="groupBucket.showNewConversationAction"
224
- type="button"
225
- h-32px px-12px
226
- flex items-center gap-9px
227
- rounded-10px
228
- text-left
229
- hover:bg-subtle
230
- cursor-pointer
231
- @click="startConversationInGroup(groupBucket.group.id)"
232
- >
233
- <span body-14-medium text-secondary leading-18px ml-24px>
234
- Nova conversa
235
- </span>
236
- <TelaIcon name="i-ph-plus" size="14px" color="icon-secondary" shrink-0 />
237
- </button>
238
- <div
239
- v-else-if="groupBucket.showSearchEmpty"
240
- h-32px flex items-center ml-36px
241
- >
242
- <span body-14-medium text-tertiary>
243
- Sem resultados
244
- </span>
245
- </div>
246
- </div>
247
-
248
- <div aria-hidden="true" absolute left-17px h-full w-0.5px bg-border pointer-events-none />
249
- </div>
250
- </TelaCollapsibleContent>
251
- </TelaCollapsible>
252
- </div>
253
- </div>
254
-
255
- <div
256
- flex="~ col" gap-4px
257
- rounded-10px
258
- :class="ungroupedDropTarget ? 'bg-lowered' : ''"
259
- @dragenter.prevent="handleDragEnter(null)"
260
- @dragover.prevent="handleDragOver($event, null)"
261
- @dragleave="handleDragLeave($event, null)"
262
- @drop.prevent="handleConversationDrop($event, null)"
263
- >
264
- <span body-12-medium text-secondary pl-10px pt-4px>
265
- {{ ungroupedConversationLabel }}
266
- </span>
267
-
268
- <div v-if="ungroupedConversations.length > 0" flex="~ col" gap-1px>
269
321
  <ChatConversationItem
270
- v-for="conversation in ungroupedConversations"
271
- :key="conversation.id"
272
- v-bind="getConversationItemProps(conversation)"
322
+ v-if="row.type === 'conversation'"
323
+ v-bind="getConversationItemProps(row.conversation)"
324
+ :item-class="row.groupId ? 'ml-24px' : void 0"
273
325
  @delete="handleDelete"
274
326
  @rename="handleRename"
275
327
  @duplicate="handleDuplicate"
@@ -277,6 +329,29 @@ const {
277
329
  @drag-start="handleConversationDragStart"
278
330
  @drag-end="clearDragState"
279
331
  />
332
+ <button
333
+ v-if="row.type === 'new'"
334
+ type="button"
335
+ h-32px px-12px w-full
336
+ flex items-center gap-9px
337
+ rounded-10px
338
+ text-left
339
+ hover:bg-subtle
340
+ cursor-pointer
341
+ @click="startConversationInGroup(row.groupBucket.group.id)"
342
+ >
343
+ <span body-14-medium text-secondary leading-18px ml-24px>
344
+ Nova conversa
345
+ </span>
346
+ <TelaIcon name="i-ph-plus" size="14px" color="icon-secondary" shrink-0 />
347
+ </button>
348
+ <div v-if="row.type === 'empty'" h-32px flex items-center ml-36px>
349
+ <span body-14-medium text-tertiary>Sem resultados</span>
350
+ </div>
351
+ <div
352
+ v-if="row.groupId && (row.type === 'conversation' || row.type === 'new' || row.type === 'empty')"
353
+ aria-hidden="true" absolute top-0 left-17px h-full w-0.5px bg-border pointer-events-none
354
+ />
280
355
  </div>
281
356
  </div>
282
357
  </div>
@@ -9,11 +9,11 @@ type __VLS_Props = {
9
9
  showCredentialsTab?: boolean;
10
10
  };
11
11
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
12
- "update:open": (value: boolean) => any;
13
12
  save: (payload: ChatConfigurationSavePayload) => any;
13
+ "update:open": (value: boolean) => any;
14
14
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
15
- "onUpdate:open"?: ((value: boolean) => any) | undefined;
16
15
  onSave?: ((payload: ChatConfigurationSavePayload) => any) | undefined;
16
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
17
17
  }>, {
18
18
  initialTab: ChatConfigurationTab;
19
19
  saving: boolean;
@@ -9,11 +9,11 @@ type __VLS_Props = {
9
9
  showCredentialsTab?: boolean;
10
10
  };
11
11
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
12
- "update:open": (value: boolean) => any;
13
12
  save: (payload: ChatConfigurationSavePayload) => any;
13
+ "update:open": (value: boolean) => any;
14
14
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
15
- "onUpdate:open"?: ((value: boolean) => any) | undefined;
16
15
  onSave?: ((payload: ChatConfigurationSavePayload) => any) | undefined;
16
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
17
17
  }>, {
18
18
  initialTab: ChatConfigurationTab;
19
19
  saving: boolean;
@@ -15,13 +15,13 @@ type __VLS_Props = {
15
15
  user?: EmbedUser | null;
16
16
  features?: Partial<ChatFeatureConfig>;
17
17
  };
18
- declare var __VLS_11: {}, __VLS_95: any, __VLS_202: any;
18
+ declare var __VLS_11: {}, __VLS_96: any, __VLS_204: any;
19
19
  type __VLS_Slots = {} & {
20
20
  'sidebar-bottom'?: (props: typeof __VLS_11) => any;
21
21
  } & {
22
- 'failed-message-actions'?: (props: typeof __VLS_95) => any;
22
+ 'failed-message-actions'?: (props: typeof __VLS_96) => any;
23
23
  } & {
24
- 'failed-message-actions'?: (props: typeof __VLS_202) => any;
24
+ 'failed-message-actions'?: (props: typeof __VLS_204) => any;
25
25
  };
26
26
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
27
27
  "update:conversationId": (value: string | null) => any;
@@ -783,6 +783,32 @@ async function handleShare() {
783
783
  statusToast.update({ text: "Erro ao copiar link", icon: "i-ph-warning" });
784
784
  }
785
785
  }
786
+ const forkEvents = shallowRef([]);
787
+ const displayConversationEvents = computed(() => [...new Map([
788
+ ...forkEvents.value,
789
+ ...props.conversationEvents ?? []
790
+ ].filter((event) => event.conversationId === currentConversation.value?.id).map((event) => [event.id, event])).values()]);
791
+ watch([
792
+ () => currentConversation.value?.id,
793
+ () => JSON.stringify(chatApi.withChatHeaders().headers)
794
+ ], async ([conversationId], _previous, onCleanup) => {
795
+ forkEvents.value = [];
796
+ if (!conversationId || !chatApi.conversationV2Enabled)
797
+ return;
798
+ const controller = new AbortController();
799
+ onCleanup(() => controller.abort());
800
+ try {
801
+ const { events } = await $fetch(
802
+ chatApi.path(`/conversations/${conversationId}/events`),
803
+ chatApi.withChatHeaders({ signal: controller.signal })
804
+ );
805
+ if (!controller.signal.aborted)
806
+ forkEvents.value = events.filter((event) => event.type === "conversation_forked");
807
+ } catch {
808
+ if (!controller.signal.aborted)
809
+ statusToast.update({ text: "Erro ao carregar a origem da conversa", icon: "i-ph-warning" });
810
+ }
811
+ }, { immediate: true });
786
812
  const forking = ref(false);
787
813
  async function handleFork(messageId) {
788
814
  const sourceId = currentConversation.value?.id;
@@ -898,7 +924,7 @@ async function handleDelete() {
898
924
 
899
925
  <div
900
926
  min-h-0
901
- overflow-y-auto
927
+ overflow-hidden
902
928
  px-4px
903
929
  :class="$slots['sidebar-bottom'] ? '' : 'flex-1'"
904
930
  :style="
@@ -1075,7 +1101,7 @@ async function handleDelete() {
1075
1101
 
1076
1102
  <ChatMessageList
1077
1103
  :messages="messages"
1078
- :events="conversationEvents"
1104
+ :events="displayConversationEvents"
1079
1105
  :streaming-active-message-id="streamingActiveMessageId"
1080
1106
  :preparing-steps="preparingSteps"
1081
1107
  :show-ttft="resolvedFeatures.showTtft"
@@ -1083,6 +1109,7 @@ async function handleDelete() {
1083
1109
  :fork-enabled="chatApi.conversationV2Enabled"
1084
1110
  :fork-disabled="forking"
1085
1111
  @fork="handleFork"
1112
+ @open-conversation="openConversation"
1086
1113
  @retry="handleRetry"
1087
1114
  >
1088
1115
  <template v-if="$slots['failed-message-actions']" #failed-message-actions="slotProps">
@@ -1288,7 +1315,7 @@ async function handleDelete() {
1288
1315
  >
1289
1316
  <ChatMessageList
1290
1317
  :messages="messages"
1291
- :events="conversationEvents"
1318
+ :events="displayConversationEvents"
1292
1319
  :streaming-active-message-id="streamingActiveMessageId"
1293
1320
  :preparing-steps="preparingSteps"
1294
1321
  :show-ttft="resolvedFeatures.showTtft"
@@ -1296,6 +1323,7 @@ async function handleDelete() {
1296
1323
  :fork-enabled="chatApi.conversationV2Enabled"
1297
1324
  :fork-disabled="forking"
1298
1325
  @fork="handleFork"
1326
+ @open-conversation="openConversation"
1299
1327
  @retry="handleRetry"
1300
1328
  >
1301
1329
  <template v-if="$slots['failed-message-actions']" #failed-message-actions="slotProps">
@@ -15,13 +15,13 @@ type __VLS_Props = {
15
15
  user?: EmbedUser | null;
16
16
  features?: Partial<ChatFeatureConfig>;
17
17
  };
18
- declare var __VLS_11: {}, __VLS_95: any, __VLS_202: any;
18
+ declare var __VLS_11: {}, __VLS_96: any, __VLS_204: any;
19
19
  type __VLS_Slots = {} & {
20
20
  'sidebar-bottom'?: (props: typeof __VLS_11) => any;
21
21
  } & {
22
- 'failed-message-actions'?: (props: typeof __VLS_95) => any;
22
+ 'failed-message-actions'?: (props: typeof __VLS_96) => any;
23
23
  } & {
24
- 'failed-message-actions'?: (props: typeof __VLS_202) => any;
24
+ 'failed-message-actions'?: (props: typeof __VLS_204) => any;
25
25
  };
26
26
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
27
27
  "update:conversationId": (value: string | null) => any;
@@ -2,6 +2,10 @@ import type { ConversationEvent } from '../../types/schemas/chat/conversations.j
2
2
  type __VLS_Props = {
3
3
  event: ConversationEvent;
4
4
  };
5
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
+ openConversation: (conversationId: string) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
+ onOpenConversation?: ((conversationId: string) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
6
10
  declare const _default: typeof __VLS_export;
7
11
  export default _default;
@@ -1,7 +1,16 @@
1
1
  <script setup>
2
+ import { getConversationForkSource } from "../conversation-timeline";
2
3
  const props = defineProps({
3
4
  event: { type: null, required: true }
4
5
  });
6
+ const emit = defineEmits(["openConversation"]);
7
+ const route = useRoute();
8
+ const forkSource = computed(() => getConversationForkSource(props.event));
9
+ const sourceLocation = computed(() => ({
10
+ path: route.path,
11
+ query: { ...route.query, conversation: void 0, conversationId: forkSource.value?.sourceConversationId },
12
+ hash: route.hash
13
+ }));
5
14
  const eventDate = computed(() => new Date(props.event.createdAt));
6
15
  const eventTime = computed(() => new Intl.DateTimeFormat("pt-BR", {
7
16
  hour: "2-digit",
@@ -16,16 +25,28 @@ const eventTime = computed(() => new Intl.DateTimeFormat("pt-BR", {
16
25
  items-center
17
26
  gap-12px
18
27
  py-6px
19
- :aria-label="`${event.actorLabel ? `${event.actorLabel} ` : ''}${event.description}, ${eventTime}`"
28
+ :aria-label="forkSource ? `Conversa iniciada a partir de ${forkSource.sourceTitle}` : `${event.actorLabel ? `${event.actorLabel} ` : ''}${event.description}, ${eventTime}`"
20
29
  >
21
- <span h-0 flex-1 border-t-0.5px border />
22
- <p body-12-regular text-tertiary text-center>
30
+ <span h-0 flex-1 border-t-0.5px border :class="forkSource && 'min-w-24px'" />
31
+ <p v-if="forkSource" body-12-regular text-secondary text-center min-w-0>
32
+ Conversa iniciada a partir de
33
+ <NuxtLink
34
+ :to="sourceLocation"
35
+ body-12-medium
36
+ underline
37
+ break-words
38
+ @click.exact.prevent="emit('openConversation', forkSource.sourceConversationId)"
39
+ >
40
+ {{ forkSource.sourceTitle }}
41
+ </NuxtLink>
42
+ </p>
43
+ <p v-else body-12-regular text-tertiary text-center>
23
44
  <span v-if="event.actorLabel" body-12-medium text-secondary>{{ event.actorLabel }}</span>
24
45
  {{ event.description }}
25
46
  <time :datetime="eventDate.toISOString()" :title="eventDate.toLocaleString('pt-BR')">
26
47
  · {{ eventTime }}
27
48
  </time>
28
49
  </p>
29
- <span h-0 flex-1 border-t-0.5px border />
50
+ <span h-0 flex-1 border-t-0.5px border :class="forkSource && 'min-w-24px'" />
30
51
  </div>
31
52
  </template>
@@ -2,6 +2,10 @@ import type { ConversationEvent } from '../../types/schemas/chat/conversations.j
2
2
  type __VLS_Props = {
3
3
  event: ConversationEvent;
4
4
  };
5
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
+ openConversation: (conversationId: string) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
+ onOpenConversation?: ((conversationId: string) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
6
10
  declare const _default: typeof __VLS_export;
7
11
  export default _default;
@@ -137,14 +137,8 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
137
137
  variant="contained"
138
138
  align="end"
139
139
  class="chat-message-bubble__content relative"
140
- :class="[isPending && 'op-80', forkEnabled && 'pr-44px!']"
140
+ :class="[isPending && 'op-80']"
141
141
  >
142
- <ChatMessageActions
143
- v-if="forkEnabled && message.status === 'completed' && !streamActive"
144
- class="chat-message-bubble__actions absolute right-8px top-8px"
145
- :disabled="forkDisabled"
146
- @fork="emit('fork', message.id)"
147
- />
148
142
  <div v-if="hasFiles" flex="~ col" gap-4px mb-8px>
149
143
  <a
150
144
  v-for="file in message.files"
@@ -204,16 +198,9 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
204
198
  max-w-prose
205
199
  class="chat-message-bubble__content relative"
206
200
  :class="[
207
- forkEnabled && 'pr-36px! min-h-24px',
208
201
  isFailed ? 'flex gap-12px items-start px-12px py-10px bg-subtle border border-solid rounded-12px w-full' : 'rounded-12px'
209
202
  ]"
210
203
  >
211
- <ChatMessageActions
212
- v-if="forkEnabled && message.status === 'completed' && !streamActive"
213
- class="chat-message-bubble__actions absolute right-0 top-0"
214
- :disabled="forkDisabled"
215
- @fork="emit('fork', message.id)"
216
- />
217
204
  <template v-if="isFailed">
218
205
  <div i-ph-warning-circle-fill text-16px text-red-600 shrink-0 mt-2px />
219
206
  <div flex flex-col gap-4px grow>
@@ -301,6 +288,7 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
301
288
  </ClientOnly>
302
289
  <div
303
290
  v-if="!isPending"
291
+ data-chat-message-footer
304
292
  mt-8px
305
293
  flex
306
294
  items-center
@@ -315,6 +303,11 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
315
303
  {{ formattedTtft }}
316
304
  </span>
317
305
  <ChatMessageFeedback v-if="hasMessageFeedback" :message="message" />
306
+ <ChatMessageActions
307
+ v-if="forkEnabled && message.role === 'assistant' && message.status === 'completed' && !streamActive"
308
+ :disabled="forkDisabled"
309
+ @fork="emit('fork', message.id)"
310
+ />
318
311
  </div>
319
312
  </div>
320
313
  </template>
@@ -322,7 +315,3 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
322
315
  </template>
323
316
  </TelaChatMessage>
324
317
  </template>
325
-
326
- <style scoped>
327
- @media (hover:hover) and (pointer:fine){.chat-message-bubble__actions{opacity:0}.chat-message-bubble:focus-within .chat-message-bubble__actions,.chat-message-bubble:hover .chat-message-bubble__actions,.chat-message-bubble__actions:has([data-state=open]){opacity:1}}
328
- </style>
@@ -25,9 +25,11 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
25
25
  scrollToBottom: any;
26
26
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
27
27
  retry: (messageId: string) => any;
28
+ openConversation: (conversationId: string) => any;
28
29
  fork: (messageId: string) => any;
29
30
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
30
31
  onRetry?: ((messageId: string) => any) | undefined;
32
+ onOpenConversation?: ((conversationId: string) => any) | undefined;
31
33
  onFork?: ((messageId: string) => any) | undefined;
32
34
  }>, {
33
35
  showTtft: boolean;
@@ -12,7 +12,7 @@ const props = defineProps({
12
12
  forkEnabled: { type: Boolean, required: false },
13
13
  forkDisabled: { type: Boolean, required: false }
14
14
  });
15
- const emit = defineEmits(["retry", "fork"]);
15
+ const emit = defineEmits(["retry", "fork", "openConversation"]);
16
16
  defineSlots();
17
17
  const conversationRef = ref(null);
18
18
  const conversationElements = computed(() => {
@@ -102,6 +102,7 @@ defineExpose({
102
102
  <ChatConversationEvent
103
103
  v-if="item.kind === 'event'"
104
104
  :event="item.value"
105
+ @open-conversation="emit('openConversation', $event)"
105
106
  />
106
107
  <ChatMessageBubble
107
108
  v-else
@@ -25,9 +25,11 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
25
25
  scrollToBottom: any;
26
26
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
27
27
  retry: (messageId: string) => any;
28
+ openConversation: (conversationId: string) => any;
28
29
  fork: (messageId: string) => any;
29
30
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
30
31
  onRetry?: ((messageId: string) => any) | undefined;
32
+ onOpenConversation?: ((conversationId: string) => any) | undefined;
31
33
  onFork?: ((messageId: string) => any) | undefined;
32
34
  }>, {
33
35
  showTtft: boolean;
@@ -1,4 +1,9 @@
1
1
  import type { ConversationEvent, Message } from '../types/schemas/chat/conversations.js';
2
+ export declare function getConversationForkSource(event: ConversationEvent): {
3
+ sourceConversationId: string;
4
+ sourceTitle: string;
5
+ copiedMessageId: string;
6
+ } | null;
2
7
  export type ConversationTimelineItem = {
3
8
  kind: 'event';
4
9
  id: string;
@@ -1,6 +1,18 @@
1
+ import { z } from "zod";
2
+ const forkSourceSchema = z.object({
3
+ sourceConversationId: z.string().uuid(),
4
+ sourceTitle: z.string(),
5
+ copiedMessageId: z.string().uuid()
6
+ });
7
+ export function getConversationForkSource(event) {
8
+ if (event.type !== "conversation_forked")
9
+ return null;
10
+ const parsed = forkSourceSchema.safeParse(event.payload);
11
+ return parsed.success ? parsed.data : null;
12
+ }
1
13
  export function mergeConversationTimeline(messages, events = []) {
2
- return [
3
- ...(events ?? []).map((event) => ({ kind: "event", id: event.id, createdAt: event.createdAt, value: event })),
14
+ const items = [
15
+ ...(events ?? []).filter((event) => event.type !== "conversation_forked").map((event) => ({ kind: "event", id: event.id, createdAt: event.createdAt, value: event })),
4
16
  ...messages.map((message) => ({ kind: "message", id: message.id, createdAt: message.createdAt, value: message }))
5
17
  ].sort((left, right) => {
6
18
  const difference = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
@@ -10,4 +22,13 @@ export function mergeConversationTimeline(messages, events = []) {
10
22
  return left.kind === "event" ? -1 : 1;
11
23
  return left.id.localeCompare(right.id);
12
24
  });
25
+ for (const event of events ?? []) {
26
+ const source = getConversationForkSource(event);
27
+ if (!source)
28
+ continue;
29
+ const index = items.findIndex((item) => item.kind === "message" && item.id === source.copiedMessageId);
30
+ if (index !== -1)
31
+ items.splice(index + 1, 0, { kind: "event", id: event.id, createdAt: event.createdAt, value: event });
32
+ }
33
+ return items;
13
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
- "version": "4.2.0-rc.4",
3
+ "version": "4.2.0-rc.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {