diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9e9733022a..1d61cf0242 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -92,6 +92,7 @@ import { isSwitchToNextTabShortcut, isSwitchToPreviousTabShortcut, isToggleResultsPaneShortcut, + isToggleAiPanelShortcut, isToggleSidebarShortcut, isToggleZenModeShortcut, isZoomInShortcut, @@ -3009,6 +3010,12 @@ async function handleKeydown(e: KeyboardEvent) { showQuickOpen.value = true; return; } + if (isToggleAiPanelShortcut(e, shortcuts)) { + e.preventDefault(); + e.stopPropagation(); + toggleRightSidebarPanel("ai"); + return; + } if (isFocusSearchShortcut(e, shortcuts)) { // Route by the event target so focus inside the shared result grid (or a // Teleported cell-detail dialog) lands on the result search, not the editor. diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 5ac1c80c1b..6a2b5042a6 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -292,6 +292,9 @@ const assistantMode = ref("ask"); // The selection is loaded asynchronously. Apply it once when this panel mounts, // but do not let later setting changes alter an active conversation. let defaultModeInitialized = false; +let initialConversationStateLoaded = false; +let initialConversationRestored = false; +let assistantViewMounted = false; watch( () => settings.isAiConfigLoaded, (loaded) => { @@ -305,6 +308,17 @@ watch( const currentSessionId = ref(""); const conversationId = ref(""); const conversations = ref([]); +function restoreInitialConversation() { + if (!assistantViewMounted || initialConversationRestored || !initialConversationStateLoaded || !settings.isAiConfigLoaded) return; + initialConversationRestored = true; + if (settings.restoreLastConversation && conversations.value[0]) { + selectConversation(conversations.value[0]); + } +} +watch( + () => settings.isAiConfigLoaded, + () => restoreInitialConversation(), +); const conversationSearchQuery = ref(""); const conversationSearchInput = ref(null); const conversationSearchIndex = computed(() => buildAiConversationSearchIndex(conversations.value)); @@ -312,7 +326,6 @@ const filteredConversations = computed(() => filterAiConversationSearchIndex(con const showConversationList = ref(false); const showTemplateSelector = ref(false); const modeActionOpen = ref(false); -let assistantViewMounted = false; // A normal-send FIFO run recovered at startup as an editable pending draft. // When the user opens that conversation, the draft is loaded into the input // box and this banner explains it is an unsent, resendable request. @@ -3934,7 +3947,11 @@ async function persistPendingInputRecovery(conversation: AiConversation, message createdAt: conversation.createdAt, updatedAt, }; - await saveAiRunState(snapshot, run).catch(() => {}); + await saveAiRunState(snapshot, run) + // Recovery updates the conversation timestamp. Keep the in-memory list in + // the same order before the initial "restore latest" decision runs. + .then(() => syncPersistedConversation(snapshot)) + .catch(() => {}); } async function persistConversation() { @@ -4314,6 +4331,10 @@ onMounted(async () => { } } } + initialConversationStateLoaded = true; + // Restore only after persisted background runs have been registered, so the + // selected conversation reflects the same runtime state as manual selection. + restoreInitialConversation(); shikiCodeHighlighter.value = await createAiShikiCodeHighlighter({ appearance: () => aiCodeAppearance.value, }).catch(() => undefined); diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a1a63295e6..732284e56f 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -7941,6 +7941,22 @@ LIMIT 100; + +
+ +
+
+ +

+ {{ t("ai.restoreLastConversationDescription") }} +

+
+ +
+
+
diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts index a7413aefe9..71bf9980c1 100644 --- a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts @@ -396,6 +396,13 @@ describe("AI assistant uses platform-specific conversation lifecycle", () => { expect(syncBody).toContain("conversations.value.sort("); }); + it("reorders recovered conversations before restoring the most recent one", () => { + const recoveryBody = bodyOf("async function persistPendingInputRecovery("); + expect(recoveryBody).toContain(".then(() => syncPersistedConversation(snapshot))"); + const mountedBody = bodyOf("onMounted(async () => {"); + expect(mountedBody.indexOf("initialConversationStateLoaded = true;")).toBeLessThan(mountedBody.indexOf("restoreInitialConversation();")); + }); + it("does not replace an in-memory desktop run when the AI panel remounts", () => { const mountedBody = bodyOf("onMounted(async () => {"); expect(mountedBody).toContain("const liveRun = desktopAiRun(persistedRun.conversationId);"); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cfc46e2546..258dedc2ea 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3001,6 +3001,8 @@ export default { maxAgentTurnsSaved: "Agent turn limit saved", defaultAiMode: "Default AI Mode", defaultAiModeDescription: "Mode used when starting a new AI conversation. Switching mode in the current conversation does not change this setting.", + restoreLastConversation: "Restore last AI conversation", + restoreLastConversationDescription: "When enabled, opening the AI assistant loads the most recently updated conversation instead of a blank one.", promptTemplates: "Scenario Prompt Templates", promptTemplatesDescription: "Select one or more templates in the AI assistant to inject scenario-specific conventions.", promptTemplateNew: "New Template", @@ -7171,6 +7173,7 @@ export default { shortcutOpenDataInNewTab: "Open data in new tab (mouse click)", shortcutViewTableDdl: "View table DDL", shortcutSendSelectionToAi: "Send selection to AI", + shortcutToggleAiPanel: "Toggle AI panel", shortcutSqlIntentionActions: "SQL Intention Actions (Expand * / Qualify Identifier)", intentionExpandWildcard: "Expand * to Column List", intentionQualifyIdentifier: "Qualify Identifier", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 33f43eda78..23b421d0c8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2788,6 +2788,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Límite de turnos del agente guardado", defaultAiMode: "Modo de IA predeterminado", defaultAiModeDescription: "Modo usado al iniciar una nueva conversación de IA. Cambiar de modo en la conversación actual no modifica esta configuración.", + restoreLastConversation: "Restaurar la última conversación de IA al abrir", + restoreLastConversationDescription: "Al activarlo, el asistente de IA carga la conversación actualizada más recientemente en lugar de una conversación en blanco.", promptTemplates: "Plantillas de prompt por escenario", promptTemplatesDescription: "Selecciona una o más plantillas en el asistente de IA para inyectar convenciones específicas del escenario.", promptTemplateNew: "Nueva plantilla", @@ -6702,6 +6704,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "Abrir datos en una pestaña nueva (clic del ratón)", shortcutViewTableDdl: "Ver DDL de la tabla", shortcutSendSelectionToAi: "Enviar selección a IA", + shortcutToggleAiPanel: "Alternar panel de IA", shortcutSqlIntentionActions: "Acciones de intención SQL (Expandir * / Calificar identificador)", intentionExpandWildcard: "Expandir * a lista de columnas", intentionQualifyIdentifier: "Calificar identificador", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 088ac6b1b9..44dbc61f94 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2682,6 +2682,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Limite di turni dell'agente salvato", defaultAiMode: "Modalità IA predefinita", defaultAiModeDescription: "Modalità usata all'avvio di una nuova conversazione IA. Cambiare modalità nella conversazione corrente non modifica questa impostazione.", + restoreLastConversation: "Ripristina l'ultima conversazione IA all'apertura", + restoreLastConversationDescription: "Quando è attiva, l'assistente IA carica la conversazione aggiornata più di recente invece di una conversazione vuota.", promptTemplates: "Modelli di prompt per scenario", promptTemplatesDescription: "Seleziona uno o più modelli nell'assistente AI per inserire convenzioni specifiche dello scenario.", promptTemplateNew: "Nuovo modello", @@ -6702,6 +6704,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "Apri dati in una nuova scheda (clic del mouse)", shortcutViewTableDdl: "Visualizza DDL tabella", shortcutSendSelectionToAi: "Invia selezione ad AI", + shortcutToggleAiPanel: "Attiva/disattiva pannello AI", shortcutSqlIntentionActions: "Azioni di intento SQL (Espandi * / Qualifica identificatore)", intentionExpandWildcard: "Espandi * in elenco colonne", intentionQualifyIdentifier: "Qualifica identificatore", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index fce1f73929..2d773bab03 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2824,6 +2824,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "エージェントターン上限を保存しました", defaultAiMode: "既定の AI モード", defaultAiModeDescription: "新しい AI 会話を開始するときに使用されるモードです。現在の会話でモードを切り替えてもこの設定は変更されません。", + restoreLastConversation: "起動時に最後の AI 会話を復元", + restoreLastConversationDescription: "有効にすると、AI アシスタントを開いたときに空白の会話ではなく、最後に更新された会話を読み込みます。", promptTemplates: "シナリオプロンプトテンプレート", promptTemplatesDescription: "AIアシスタントで1つ以上のテンプレートを選択し、シナリオ固有の規約を注入します。", promptTemplateNew: "新規テンプレート", @@ -6710,6 +6712,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "新しいデータタブで開く(マウスクリック)", shortcutViewTableDdl: "テーブルDDLを表示", shortcutSendSelectionToAi: "選択範囲をAIに送信", + shortcutToggleAiPanel: "AIパネルを切り替え", shortcutSqlIntentionActions: "SQL インテント操作(* の展開 / 識別子の修飾)", intentionExpandWildcard: "* を列リストに展開", intentionQualifyIdentifier: "識別子を修飾", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9dc7e53a9d..758e9fbcb2 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2794,6 +2794,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "에이전트 턴 제한을 저장했습니다", defaultAiMode: "기본 AI 모드", defaultAiModeDescription: "새 AI 대화를 시작할 때 사용되는 모드입니다. 현재 대화에서 모드를 전환해도 이 설정은 변경되지 않습니다.", + restoreLastConversation: "열 때 마지막 AI 대화 복원", + restoreLastConversationDescription: "사용하면 AI 어시스턴트를 열 때 빈 대화 대신 가장 최근에 업데이트된 대화를 불러옵니다.", promptTemplates: "시나리오 프롬프트 템플릿", promptTemplatesDescription: "AI 어시스턴트에서 하나 이상의 템플릿을 선택하여 시나리오별 규칙을 주입합니다.", promptTemplateNew: "새 템플릿", @@ -6457,6 +6459,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "새 탭에서 데이터 열기 (마우스 클릭)", shortcutViewTableDdl: "테이블 DDL 보기", shortcutSendSelectionToAi: "선택 영역을 AI로 보내기", + shortcutToggleAiPanel: "AI 패널 전환", shortcutSqlIntentionActions: "SQL 인텐트 동작 (* 펼치기 / 식별자 한정)", intentionExpandWildcard: "* 을(를) 열 목록으로 펼치기", intentionQualifyIdentifier: "식별자 한정", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bb7628a898..86f288e50b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2790,6 +2790,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Limite de turnos do agente salvo", defaultAiMode: "Modo de IA padrão", defaultAiModeDescription: "Modo usado ao iniciar uma nova conversa de IA. Alterar o modo na conversa atual não altera esta configuração.", + restoreLastConversation: "Restaurar a última conversa de IA ao abrir", + restoreLastConversationDescription: "Quando ativado, o assistente de IA carrega a conversa atualizada mais recentemente em vez de uma conversa vazia.", promptTemplates: "Modelos de prompt por cenário", promptTemplatesDescription: "Selecione um ou mais modelos no assistente de IA para injetar convenções específicas do cenário.", promptTemplateNew: "Novo modelo", @@ -6704,6 +6706,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "Abrir dados em nova aba (clique do mouse)", shortcutViewTableDdl: "Ver DDL da tabela", shortcutSendSelectionToAi: "Enviar seleção para IA", + shortcutToggleAiPanel: "Alternar painel de IA", shortcutSqlIntentionActions: "Ações de intenção SQL (Expandir * / Qualificar identificador)", intentionExpandWildcard: "Expandir * para lista de colunas", intentionQualifyIdentifier: "Qualificar identificador", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index e225272136..f567c411c7 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2975,6 +2975,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Agent tur sınırı kaydedildi", defaultAiMode: "Varsayılan Yapay Zekâ Modu", defaultAiModeDescription: "Yeni bir yapay zekâ konuşması başlatılırken kullanılan mod. Geçerli konuşmada mod değiştirmek bu ayarı değiştirmez.", + restoreLastConversation: "Açılışta son yapay zekâ konuşmasını geri yükle", + restoreLastConversationDescription: "Etkinleştirildiğinde, yapay zekâ asistanı boş bir konuşma yerine en son güncellenen konuşmayı yükler.", promptTemplates: "Senaryo İstem Şablonları", promptTemplatesDescription: "Senaryoya özgü kuralları eklemek için yapay zekâ asistanında bir veya daha fazla şablon seçin.", promptTemplateNew: "Yeni Şablon", @@ -7054,6 +7056,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "Verileri yeni sekmede aç (fare tıklaması)", shortcutViewTableDdl: "Tablo DDL'ini görüntüle", shortcutSendSelectionToAi: "Seçimi yapay zekâya gönder", + shortcutToggleAiPanel: "Yapay zeka panelini değiştir", shortcutSqlIntentionActions: "SQL Amaç İşlemleri (* Genişlet / Tanımlayıcıyı Nitele)", intentionExpandWildcard: "* İfadesini Sütun Listesine Genişlet", intentionQualifyIdentifier: "Tanımlayıcıyı Nitele", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8f3e29989c..40143c0af9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2924,6 +2924,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Agent 回合上限已保存", defaultAiMode: "默认 AI 模式", defaultAiModeDescription: "新建 AI 对话时使用的模式。切换当前对话的模式不会修改此设置。", + restoreLastConversation: "打开时恢复最近的 AI 对话", + restoreLastConversationDescription: "开启后,打开 AI 助手时会加载最近更新的对话,而不是显示空白新对话。", promptTemplates: "场景 Prompt 模板", promptTemplatesDescription: "在 AI 助手中选择一个或多个模板,注入场景化的约定规范。", promptTemplateNew: "新建模板", @@ -7156,6 +7158,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "在新数据标签页中打开(鼠标点击)", shortcutViewTableDdl: "查看表 DDL", shortcutSendSelectionToAi: "发送选中代码到 AI", + shortcutToggleAiPanel: "切换 AI 面板", shortcutSqlIntentionActions: "SQL 意图操作(展开 * / 限定标识符)", intentionExpandWildcard: "展开 * 为列名列表", intentionQualifyIdentifier: "限定标识符", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6a5e3a47c4..6088cd2e93 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2681,6 +2681,8 @@ export default withEnglishFallback({ maxAgentTurnsSaved: "Agent 回合上限已儲存", defaultAiMode: "預設 AI 模式", defaultAiModeDescription: "建立新 AI 對話時使用的模式。切換目前對話的模式不會變更此設定。", + restoreLastConversation: "開啟時恢復最近的 AI 對話", + restoreLastConversationDescription: "開啟後,開啟 AI 助手時會載入最近更新的對話,而不是顯示空白新對話。", promptTemplates: "場景 Prompt 範本", promptTemplatesDescription: "在 AI 助手中選擇一或多個範本,注入場景化的約定規範。", promptTemplateNew: "新增範本", @@ -6000,6 +6002,7 @@ export default withEnglishFallback({ shortcutOpenDataInNewTab: "在新資料分頁中開啟(滑鼠點擊)", shortcutViewTableDdl: "檢視資料表 DDL", shortcutSendSelectionToAi: "傳送選取程式碼至 AI", + shortcutToggleAiPanel: "切換 AI 面板", shortcutSqlIntentionActions: "SQL 意圖操作(展開 * / 限定識別碼)", intentionExpandWildcard: "展開 * 為欄位清單", intentionQualifyIdentifier: "限定識別碼", diff --git a/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts b/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts index 9ac09899da..f33a57645e 100644 --- a/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts @@ -21,6 +21,11 @@ describe("right sidebar panel entry points", () => { expect(appSource).toContain("@close=\"closeRightSidebarPanel('sqlFile')\""); }); + it("toggles the AI panel through the configured global shortcut", () => { + expect(appSource).toContain("isToggleAiPanelShortcut(e, shortcuts)"); + expect(appSource).toContain('toggleRightSidebarPanel("ai");'); + }); + it("routes welcome, history analysis, selection, and error-fix opens through the same controller", () => { expect(appSource).toContain("@show-history=\"openRightSidebarPanel('history')\""); expect(functionSource("fixWithAi", "sendSelectionToAi")).toContain('openRightSidebarPanel("ai")'); diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index e08227f390..6a8b32756d 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -11,6 +11,7 @@ import { isGoToNextPageShortcut, isGoToPreviousPageShortcut, isToggleZenModeShortcut, + isToggleAiPanelShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut, @@ -122,6 +123,13 @@ describe("keyboard shortcut matching", () => { expect(isToggleZenModeShortcut(platformModEvent, { toggleZenMode: "" })).toBe(false); }); + it("matches the platform-specific AI panel shortcut", () => { + expect(isToggleAiPanelShortcut({ key: "i", ctrlKey: true, metaKey: true }, undefined, "MacIntel")).toBe(true); + expect(isToggleAiPanelShortcut({ key: "i", ctrlKey: true, altKey: true }, undefined, "Win32")).toBe(true); + expect(isToggleAiPanelShortcut({ key: "a", ctrlKey: true, shiftKey: true }, { toggleAiPanel: "Mod+Shift+A" }, "Win32")).toBe(true); + expect(isToggleAiPanelShortcut({ key: "i", ctrlKey: true, metaKey: true }, { toggleAiPanel: "" }, "MacIntel")).toBe(false); + }); + it("matches legacy plus-key shortcuts saved with plus as a separator", () => { expect(matchesShortcut({ key: "+", ctrlKey: true }, "Mod++", "Win32")).toBe(true); expect(matchesShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Shift+Mod++", "Win32")).toBe(true); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 011eb677b3..cd72c5f739 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -9,6 +9,7 @@ import { normalizeShortcutSettings, selectionOccurrenceDefaultShortcut, shortcutToCodeMirrorKey, + toggleAiPanelDefaultShortcut, type ShortcutActionId, } from "@/lib/editor/shortcutRegistry"; @@ -169,6 +170,17 @@ describe("shortcutRegistry editor actions", () => { expect(findShortcutConflict("toggleZenMode", DEFAULT_SHORTCUT_SETTINGS.toggleZenMode, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); }); + it("uses a platform-specific shortcut for toggling the AI panel", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "toggleAiPanel"); + + expect(definition).toMatchObject({ id: "toggleAiPanel", labelKey: "settings.shortcutToggleAiPanel", scope: "global" }); + expect(toggleAiPanelDefaultShortcut("MacIntel")).toBe("Ctrl+Mod+I"); + expect(toggleAiPanelDefaultShortcut("Win32")).toBe("Ctrl+Alt+I"); + expect(normalizeShortcutSettings({ toggleAiPanel: "Ctrl+Alt+I" }, "MacIntel").toggleAiPanel).toBe("Ctrl+Mod+I"); + expect(normalizeShortcutSettings({ toggleAiPanel: "Ctrl+Mod+I" }, "Win32").toggleAiPanel).toBe("Ctrl+Alt+I"); + expect(findShortcutConflict("toggleAiPanel", normalizeShortcutSettings().toggleAiPanel, normalizeShortcutSettings())).toBeNull(); + }); + it("uses Shift+Enter for inserting a complete line below", () => { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "insertLineBelow"); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index ff4dc82650..9fd2d8c48e 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -145,6 +145,10 @@ export function isSendSelectionToAiShortcut(event: ShortcutLikeEvent, shortcuts? return matchesShortcut(event, actionShortcut("sendSelectionToAi", shortcuts)); } +export function isToggleAiPanelShortcut(event: ShortcutLikeEvent, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): boolean { + return matchesShortcut(event, actionShortcut("toggleAiPanel", shortcuts, platform), platform); +} + export function isConvertNamingStyleShortcut(event: ShortcutLikeEvent, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): boolean { return matchesShortcut(event, actionShortcut("convertNamingStyle", shortcuts, platform), platform); } diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 2088757f42..38aa4cde8b 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -45,6 +45,7 @@ export type ShortcutActionId = | "closeOtherTabs" | "focusSearch" | "quickOpen" + | "toggleAiPanel" | "navigateTabHistoryBack" | "navigateTabHistoryForward" | "tabSwitcher" @@ -118,12 +119,19 @@ export function tabNavigationHistoryDefaultShortcut(direction: "back" | "forward return `${modifier}+Alt+${key}`; } +// Match the shortcut used by VS Code to open its chat sidebar on macOS while +// keeping a reachable, non-conflicting equivalent on Windows/Linux. +export function toggleAiPanelDefaultShortcut(platform = globalThis.navigator?.platform || ""): string { + return isMacShortcutPlatform(platform) ? "Ctrl+Mod+I" : "Ctrl+Alt+I"; +} + const PLATFORM_DEFAULT_SHORTCUTS: Partial>> = { closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]), navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]), navigateTabHistoryForward: new Set(["Ctrl+Alt+ArrowRight", "Mod+Alt+ArrowRight"]), addNextSelectionOccurrence: new Set(["Ctrl+G", "Alt+J"]), selectAllSelectionOccurrences: new Set(["Ctrl+Mod+G", "Ctrl+Alt+Shift+J"]), + toggleAiPanel: new Set(["Ctrl+Mod+I", "Ctrl+Alt+I"]), }; const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W"; const LEGACY_COPY_CURRENT_ROW_DEFAULT = "Mod+D"; @@ -395,6 +403,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "global", defaultShortcut: "Mod+P", }, + { + id: "toggleAiPanel", + labelKey: "settings.shortcutToggleAiPanel", + scope: "global", + defaultShortcut: toggleAiPanelDefaultShortcut(), + }, { id: "navigateTabHistoryBack", labelKey: "settings.shortcutNavigateTabHistoryBack", @@ -614,6 +628,7 @@ function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: st if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform); if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform); if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform); + if (definition.id === "toggleAiPanel") return toggleAiPanelDefaultShortcut(platform); return definition.defaultShortcut; } diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 8eb6cc459b..37bc4dcb2a 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -1604,6 +1604,7 @@ describe("settingsStore activeModel lifecycle", () => { active: undefined, effortPreferences: [], defaultMode: "ask", + restoreLastConversation: false, }), ); @@ -1691,6 +1692,7 @@ describe("settingsStore activeModel lifecycle", () => { }, ], defaultMode: "ask", + restoreLastConversation: false, }); }); @@ -1758,6 +1760,27 @@ describe("settingsStore defaultAiMode lifecycle", () => { expect(store.defaultAiMode).toBe("agent"); }); + it("restores and persists the last conversation preference", async () => { + const saveAiChatSelection = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue([]), + loadAiConfig: vi.fn().mockResolvedValue(null), + loadAiProviderConfigs: vi.fn().mockResolvedValue(null), + loadAiChatSelection: vi.fn().mockResolvedValue({ version: 1, effortPreferences: [], restoreLastConversation: true }), + saveAiChatSelection, + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + await store.initAiConfigs(); + + expect(store.restoreLastConversation).toBe(true); + store.setRestoreLastConversation(false); + store.setRestoreLastConversation(true); + + await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenLastCalledWith(expect.objectContaining({ restoreLastConversation: true }))); + }); + it("setDefaultAiMode updates state and persists the mode", async () => { const saveAiChatSelection = vi.fn().mockResolvedValue(undefined); vi.doMock("@/lib/backend/api", () => ({ diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 1a98e50eac..bac636285b 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -1671,6 +1671,7 @@ export const useSettingsStore = defineStore("settings", () => { const activeModel = ref<{ configId: string; modelId: string } | null>(null); const effortPreferences = ref([]); const defaultAiMode = ref("ask"); + const restoreLastConversation = ref(false); // Per-db_type prompt template defaults (explicit opt-in) and last-used // fallback; both resolved when an AI panel mounts or its namespace changes. const aiDefaultTemplatesByDbType = ref>({}); @@ -1874,6 +1875,7 @@ export const useSettingsStore = defineStore("settings", () => { const savedSelection = await api.loadAiChatSelection().catch(() => null); effortPreferences.value = (savedSelection?.effortPreferences ?? []).filter((preference) => aiConfigs.value.some((config) => config.id === preference.configId)); defaultAiMode.value = savedSelection?.defaultMode ?? "ask"; + restoreLastConversation.value = savedSelection?.restoreLastConversation ?? false; aiDefaultTemplatesByDbType.value = normalizeTemplateIdsByDbType(savedSelection?.defaultTemplatesByDbType); aiLastUsedTemplatesByDbType.value = normalizeTemplateIdsByDbType(savedSelection?.lastUsedTemplatesByDbType); @@ -2014,6 +2016,12 @@ export const useSettingsStore = defineStore("settings", () => { persistAiChatSelection(); } + function setRestoreLastConversation(value: boolean) { + if (value === restoreLastConversation.value) return; + restoreLastConversation.value = value; + persistAiChatSelection(); + } + /** Empty id list clears the db_type entry so unsetting a default is expressible. */ function setDefaultTemplatesForDbType(dbType: string, templateIds: string[]) { if (!dbType) return; @@ -2074,6 +2082,7 @@ export const useSettingsStore = defineStore("settings", () => { selection: { ...preference.selection }, })), defaultMode: defaultAiMode.value, + restoreLastConversation: restoreLastConversation.value, // Match the backend's skip_serializing_if(empty): omit the per-db_type // records entirely while nothing is configured so the payload stays // identical to the pre-defaults format for users without template picks. @@ -2460,6 +2469,8 @@ export const useSettingsStore = defineStore("settings", () => { activeEffort, defaultAiMode, setDefaultAiMode, + restoreLastConversation, + setRestoreLastConversation, aiDefaultTemplatesByDbType, aiLastUsedTemplatesByDbType, setDefaultTemplatesForDbType, diff --git a/apps/desktop/src/types/ai.ts b/apps/desktop/src/types/ai.ts index 67e3ed2e8d..6eace4fedb 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -96,6 +96,8 @@ export interface AiChatSelectionState { active?: AiActiveModelSelection; effortPreferences: AiModelEffortPreference[]; defaultMode?: AiAssistantMode; + /** Whether opening the AI panel restores the most recently updated conversation. */ + restoreLastConversation?: boolean; /** Prompt template ids auto-applied when the AI panel opens, keyed by connection db_type. */ defaultTemplatesByDbType?: Record; /** Prompt template ids from the most recent send, keyed by connection db_type. */ diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 936563673d..e206a93f64 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -313,6 +313,9 @@ pub struct AiChatSelectionState { pub effort_preferences: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_mode: Option, + /// Whether opening the AI panel should restore the most recently updated conversation. + #[serde(default)] + pub restore_last_conversation: bool, /// Prompt template ids auto-applied when the AI panel opens, keyed by /// connection db_type. BTreeMap keeps serialized key order stable. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -330,6 +333,7 @@ impl Default for AiChatSelectionState { active: None, effort_preferences: Vec::new(), default_mode: None, + restore_last_conversation: false, default_templates_by_db_type: BTreeMap::new(), last_used_templates_by_db_type: BTreeMap::new(), } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 64978a6e10..70e35cbf00 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -7400,6 +7400,7 @@ mod tests { selection: AiEffortSelection::Enum("high".to_string()), }], default_mode: Some(AiAssistantMode::Agent), + restore_last_conversation: true, default_templates_by_db_type: BTreeMap::from([("postgresql".to_string(), vec!["tpl-1".to_string()])]), last_used_templates_by_db_type: BTreeMap::from([("mysql".to_string(), vec!["tpl-2".to_string()])]), };