feat(ai): rename conversations and improve database picker
This commit is contained in:
12 files changed
+134
-7
No files matched your search
@@ -323,6 +323,10 @@ const conversationSearchInput = ref<HTMLInputElement | null>(null);
|
||||
const conversationSearchIndex = computed(() => buildAiConversationSearchIndex(conversations.value));
|
||||
const filteredConversations = computed(() => filterAiConversationSearchIndex(conversationSearchIndex.value, conversationSearchQuery.value));
|
||||
const showConversationList = ref(false);
|
||||
const renamingConversationId = ref<string | null>(null);
|
||||
const renamingConversationTitle = ref("");
|
||||
const renamingConversationInput = ref<HTMLInputElement | null>(null);
|
||||
const renamedConversationTitles = reactive(new Map<string, string>());
|
||||
const showTemplateSelector = ref(false);
|
||||
const modeActionOpen = ref(false);
|
||||
// A normal-send FIFO run recovered at startup as an editable pending draft.
|
||||
@@ -1407,6 +1411,12 @@ const dbSelectOptions = computed(() => {
|
||||
// AI can inspect more than the tab's active database. Keep this selection local
|
||||
// to the composer so changing the visible query tab does not rewrite SQL state.
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
const databaseSearchQuery = ref("");
|
||||
const filteredDbSelectOptions = computed(() => {
|
||||
const query = databaseSearchQuery.value.trim().toLowerCase();
|
||||
if (!query) return dbSelectOptions.value;
|
||||
return dbSelectOptions.value.filter((option) => option.label.toLowerCase().includes(query) || option.database.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const selectedDatabaseValues = computed(() => new Set(selectedDatabases.value));
|
||||
const selectedDatabaseLabel = computed(() => {
|
||||
@@ -3835,9 +3845,10 @@ function clearAttachmentDraftState() {
|
||||
function buildConversationSnapshot(targetConversationId: string, targetMessages: ChatMessage[], connectionName: string, database: string, createdAt = new Date().toISOString()): AiConversation | null {
|
||||
if (!targetConversationId || !targetMessages.length) return null;
|
||||
const first = targetMessages.find((m) => m.role === "user" && m.kind !== "contextSummary");
|
||||
const existingConversation = conversations.value.find((conversation) => conversation.id === targetConversationId);
|
||||
return {
|
||||
id: targetConversationId,
|
||||
title: first ? messageTitle(first).slice(0, 50) : "Untitled",
|
||||
title: renamedConversationTitles.get(targetConversationId) || existingConversation?.title || (first ? messageTitle(first).slice(0, 50) : "Untitled"),
|
||||
connectionName,
|
||||
database,
|
||||
messages: targetMessages.map((m) => ({
|
||||
@@ -3992,6 +4003,38 @@ async function setConversationListOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startRenameConversation(conv: AiConversation) {
|
||||
renamingConversationId.value = conv.id;
|
||||
renamingConversationTitle.value = conv.title;
|
||||
await nextTick();
|
||||
renamingConversationInput.value?.focus();
|
||||
renamingConversationInput.value?.select();
|
||||
}
|
||||
function cancelRenameConversation() {
|
||||
renamingConversationId.value = null;
|
||||
renamingConversationTitle.value = "";
|
||||
}
|
||||
async function commitRenameConversation(conv: AiConversation) {
|
||||
const title = renamingConversationTitle.value.trim().slice(0, 50);
|
||||
if (!title || title === conv.title) return cancelRenameConversation();
|
||||
try {
|
||||
const activeRun = desktopAiRun<ChatMessage>(conv.id);
|
||||
if (activeRun) await runSnapshotScheduler.save(activeRun);
|
||||
const latestConversation = conversations.value.find((item) => item.id === conv.id) ?? conv;
|
||||
const updated = { ...latestConversation, title, updatedAt: new Date().toISOString() };
|
||||
// Claim the title before the async save so a throttled snapshot firing
|
||||
// during the await window cannot persist the previous title over it.
|
||||
renamedConversationTitles.set(conv.id, title);
|
||||
await saveAiConversation(updated);
|
||||
const i = conversations.value.findIndex((item) => item.id === conv.id);
|
||||
if (i >= 0) conversations.value[i] = updated;
|
||||
} catch {
|
||||
toast(t("ai.conversationRenameFailed"), 5000);
|
||||
} finally {
|
||||
cancelRenameConversation();
|
||||
}
|
||||
}
|
||||
|
||||
function chatMessagesFromConversation(conv: AiConversation): ChatMessage[] {
|
||||
return conv.messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
@@ -4580,7 +4623,22 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
<div v-else class="max-h-64 overflow-auto p-1">
|
||||
<div v-for="conv in filteredConversations" :key="conv.id" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted" :class="{ 'bg-muted': conv.id === conversationId }" @click="selectConversation(conv)">
|
||||
<span class="min-w-0 flex-1 truncate" :title="conv.title">{{ conv.title }}</span>
|
||||
<input
|
||||
v-if="renamingConversationId === conv.id"
|
||||
:ref="
|
||||
(element) => {
|
||||
renamingConversationInput = element as HTMLInputElement | null;
|
||||
}
|
||||
"
|
||||
v-model="renamingConversationTitle"
|
||||
class="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs"
|
||||
@click.stop
|
||||
@keydown.enter.stop.prevent="commitRenameConversation(conv)"
|
||||
@keydown.esc.stop.prevent="cancelRenameConversation"
|
||||
@blur="commitRenameConversation(conv)"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate" :title="conv.title">{{ conv.title }}</span>
|
||||
<button v-if="renamingConversationId !== conv.id" type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground" :title="t('ai.renameConversation')" @click.stop="startRenameConversation(conv)"><Pencil class="h-3 w-3" /></button>
|
||||
<span v-if="conversationRowDetail(conv).hasQueuedInput" class="shrink-0 rounded border border-primary/40 bg-primary/10 px-1 py-px text-[10px] text-primary" :aria-label="t('ai.rowQueuedInput')" :title="t('ai.rowQueuedInput')">{{ t("ai.rowQueuedInput") }}</span>
|
||||
<span v-if="conversationRowDetail(conv).status === 'preparing' || conversationRowDetail(conv).status === 'running'" class="flex min-w-0 shrink-0 items-center gap-1 text-muted-foreground" :aria-label="t('ai.runStatusRunning')" :title="t('ai.runStatusRunning')">
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
|
||||
@@ -5011,11 +5069,18 @@ async function openExternalUrl(url: string) {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-64 p-1">
|
||||
<button v-for="option in dbSelectOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
|
||||
<Check :class="['h-4 w-4', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
<div v-if="!dbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("editor.selectDatabase") }}</div>
|
||||
<div class="relative mb-1 flex items-center border-b px-2 py-1">
|
||||
<Search class="pointer-events-none absolute left-3 h-3 w-3 text-muted-foreground" />
|
||||
<input v-model="databaseSearchQuery" type="search" class="h-6 w-full bg-transparent pl-5 text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('ai.searchDatabases')" />
|
||||
</div>
|
||||
<button v-if="selectedDatabases.length > 1" type="button" class="mb-1 flex w-full items-center justify-end gap-1 border-b px-2 py-1 text-xs" @click.stop="selectedDatabases = []"><X class="h-3 w-3" />{{ t("ai.clearDatabaseSelection") }}</button>
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
||||
<button v-for="option in filteredDbSelectOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
|
||||
<Check :class="['h-4 w-4', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredDbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("ai.noDatabasesFound") }}</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<template v-if="showAiSchemaSelector">
|
||||
|
||||
@@ -32,6 +32,10 @@ const AI_HTML_PREVIEW_KEYS = ["htmlPreviewLabel", "htmlExpandPreview", "htmlExpa
|
||||
// header menu entry, report role labels, failure marker, and empty-state copy.
|
||||
const AI_CONVERSATION_EXPORT_KEYS = ["exportConversation", "conversationExportMarkdown", "conversationExportHtml", "conversationRoleUser", "conversationRoleAssistant", "conversationFailedMarker", "conversationExportEmpty"] as const;
|
||||
|
||||
// Keys conversation rename and the database picker add to the `ai` namespace —
|
||||
// the picker renders in every locale and a missing key leaks raw keys into it.
|
||||
const AI_CONVERSATION_MANAGEMENT_KEYS = ["renameConversation", "conversationRenameFailed", "clearDatabaseSelection", "searchDatabases", "noDatabasesFound"] as const;
|
||||
|
||||
describe("AI rich content locale parity", () => {
|
||||
it.each(locales)("%s exposes the full ai.html* key set with non-empty copy", (_name, locale) => {
|
||||
const ai = (locale as { ai: Record<string, unknown> }).ai;
|
||||
@@ -48,4 +52,12 @@ describe("AI rich content locale parity", () => {
|
||||
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(locales)("%s exposes the full conversation-management key set with non-empty copy", (_name, locale) => {
|
||||
const ai = (locale as { ai: Record<string, unknown> }).ai;
|
||||
for (const key of AI_CONVERSATION_MANAGEMENT_KEYS) {
|
||||
expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string");
|
||||
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3150,6 +3150,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "Bu cavabı yaratmaq mümkün olmadı",
|
||||
conversationExportEmpty: "Hələ ixrac ediləcək mesaj yoxdur",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Verilənlər bazalarında axtarış…",
|
||||
noDatabasesFound: "Verilənlər bazası tapılmadı",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Əlaqəni aç",
|
||||
|
||||
@@ -3283,6 +3283,11 @@ export default {
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "This reply failed to generate",
|
||||
conversationExportEmpty: "No messages to export yet",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Search databases…",
|
||||
noDatabasesFound: "No databases found",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Open Connection",
|
||||
|
||||
@@ -3061,6 +3061,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "IA",
|
||||
conversationFailedMarker: "Esta respuesta no se pudo generar",
|
||||
conversationExportEmpty: "Aún no hay mensajes para exportar",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Buscar bases de datos…",
|
||||
noDatabasesFound: "No se encontraron bases de datos",
|
||||
agentSteps: {
|
||||
generated: "SQL generado",
|
||||
noSql: "No se encontró SQL",
|
||||
|
||||
@@ -2955,6 +2955,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "IA",
|
||||
conversationFailedMarker: "Generazione di questa risposta non riuscita",
|
||||
conversationExportEmpty: "Nessun messaggio da esportare",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Cerca database…",
|
||||
noDatabasesFound: "Nessun database trovato",
|
||||
agentSteps: {
|
||||
generated: "SQL generato",
|
||||
noSql: "Nessun SQL trovato",
|
||||
|
||||
@@ -3097,6 +3097,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "この返信の生成に失敗しました",
|
||||
conversationExportEmpty: "エクスポートできるメッセージがありません",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "データベースを検索…",
|
||||
noDatabasesFound: "データベースが見つかりません",
|
||||
agentSteps: {
|
||||
generated: "SQL生成完了",
|
||||
noSql: "SQLが見つかりません",
|
||||
|
||||
@@ -3076,6 +3076,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "이 응답 생성에 실패했습니다",
|
||||
conversationExportEmpty: "내보낼 메시지가 없습니다",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "데이터베이스 검색…",
|
||||
noDatabasesFound: "데이터베이스를 찾을 수 없습니다",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "연결 열기",
|
||||
|
||||
@@ -3063,6 +3063,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "IA",
|
||||
conversationFailedMarker: "Falha ao gerar esta resposta",
|
||||
conversationExportEmpty: "Ainda não há mensagens para exportar",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Pesquisar bancos de dados…",
|
||||
noDatabasesFound: "Nenhum banco de dados encontrado",
|
||||
agentSteps: {
|
||||
generated: "SQL gerado",
|
||||
noSql: "Nenhum SQL encontrado",
|
||||
|
||||
@@ -3123,6 +3123,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "Bu yanıt oluşturulamadı",
|
||||
conversationExportEmpty: "Dışa aktarılacak mesaj yok",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "Veritabanlarında ara…",
|
||||
noDatabasesFound: "Veritabanı bulunamadı",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Bağlantıyı Aç",
|
||||
|
||||
@@ -3197,6 +3197,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "此回复生成失败",
|
||||
conversationExportEmpty: "暂无可导出的消息",
|
||||
renameConversation: "重命名会话",
|
||||
conversationRenameFailed: "会话重命名失败",
|
||||
clearDatabaseSelection: "清空选择",
|
||||
searchDatabases: "搜索数据库…",
|
||||
noDatabasesFound: "未找到数据库",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "打开连接",
|
||||
|
||||
@@ -3175,6 +3175,11 @@ export default withEnglishFallback({
|
||||
conversationRoleAssistant: "AI",
|
||||
conversationFailedMarker: "此回覆產生失敗",
|
||||
conversationExportEmpty: "尚無可匯出的訊息",
|
||||
renameConversation: "Rename conversation",
|
||||
conversationRenameFailed: "Failed to rename conversation",
|
||||
clearDatabaseSelection: "Clear selection",
|
||||
searchDatabases: "搜尋資料庫…",
|
||||
noDatabasesFound: "未找到資料庫",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "開啟連線",
|
||||
|
||||
Reference in new issue
Block a user