@meistrari/chat-nuxt 4.2.0-rc.5 → 4.2.0-rc.7

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,7 +35,7 @@ Runtime internals under `src/runtime/**`, generated Nuxt aliases, and workspace-
35
35
 
36
36
  ## Conversation forks
37
37
 
38
- Completed assistant responses offer **Iniciar nova conversa a partir daqui** in the three-dot
38
+ Completed assistant responses offer **Continuar em nova conversa** in the three-dot
39
39
  menu below the response, beside the feedback controls. User messages cannot be forked.
40
40
  The embed selects the new conversation and emits its existing `update:conversationId` event.
41
41
  The new conversation stays idle until the user sends a message.
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.5",
4
+ "version": "4.2.0-rc.7",
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;
@@ -924,7 +924,7 @@ async function handleDelete() {
924
924
 
925
925
  <div
926
926
  min-h-0
927
- overflow-y-auto
927
+ overflow-hidden
928
928
  px-4px
929
929
  :class="$slots['sidebar-bottom'] ? '' : 'flex-1'"
930
930
  :style="
@@ -8,7 +8,7 @@ const emit = defineEmits(["fork"]);
8
8
  <template>
9
9
  <div>
10
10
  <TelaDropdownMenu
11
- :items="[{ label: 'Iniciar nova conversa a partir daqui', icon: 'i-ph-git-branch', disabled, click: () => emit('fork') }]"
11
+ :items="[{ label: 'Continuar em nova conversa', icon: 'i-ph-git-branch', disabled, click: () => emit('fork') }]"
12
12
  align="end"
13
13
  :should-be-modal="false"
14
14
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
- "version": "4.2.0-rc.5",
3
+ "version": "4.2.0-rc.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {