feat(ai): add panel shortcut and conversation restore
This commit is contained in:
23 files changed
+165
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -292,6 +292,9 @@ const assistantMode = ref<AiAssistantMode>("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<AiConversation[]>([]);
|
||||
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<HTMLInputElement | null>(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);
|
||||
|
||||
@@ -7941,6 +7941,22 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore last AI conversation (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="ai-restore-last-conversation">
|
||||
{{ t("ai.restoreLastConversation") }}
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("ai.restoreLastConversationDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="ai-restore-last-conversation" :model-value="settingsStore.restoreLastConversation" @update:model-value="(value) => settingsStore.setRestoreLastConversation(Boolean(value))" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max Retries (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
|
||||
+7
@@ -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<ChatMessage>(persistedRun.conversationId);");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "識別子を修飾",
|
||||
|
||||
@@ -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: "식별자 한정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "限定标识符",
|
||||
|
||||
@@ -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: "限定識別碼",
|
||||
|
||||
@@ -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")');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -145,6 +145,10 @@ export function isSendSelectionToAiShortcut(event: ShortcutLikeEvent, shortcuts?
|
||||
return matchesShortcut(event, actionShortcut("sendSelectionToAi", shortcuts));
|
||||
}
|
||||
|
||||
export function isToggleAiPanelShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): boolean {
|
||||
return matchesShortcut(event, actionShortcut("toggleAiPanel", shortcuts, platform), platform);
|
||||
}
|
||||
|
||||
export function isConvertNamingStyleShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): boolean {
|
||||
return matchesShortcut(event, actionShortcut("convertNamingStyle", shortcuts, platform), platform);
|
||||
}
|
||||
|
||||
@@ -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<Record<ShortcutActionId, ReadonlySet<string>>> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -1671,6 +1671,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
|
||||
const effortPreferences = ref<AiModelEffortPreference[]>([]);
|
||||
const defaultAiMode = ref<AiAssistantMode>("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<Record<string, string[]>>({});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
/** Prompt template ids from the most recent send, keyed by connection db_type. */
|
||||
|
||||
Reference in new issue
Block a user