From 2927e01591f4c4cefe78d688dcd0066c35b730a2 Mon Sep 17 00:00:00 2001 From: Xinbeok Date: Sun, 23 Aug 2026 18:29:35 +0800 Subject: [PATCH] feat(ai): add resizable maximized chat panel --- apps/desktop/src/App.vue | 31 +++++-- .../src/components/editor/AiAssistant.vue | 10 ++- .../__tests__/AiAssistant.maximize.spec.ts | 17 ++++ .../__tests__/usePanelResize.spec.ts | 82 +++++++++++++++++++ .../desktop/src/composables/usePanelResize.ts | 28 ++++++- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/app/rightSidebarPanels.spec.ts | 9 ++ 14 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/AiAssistant.maximize.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/usePanelResize.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9242dd6d7d..21618417ac 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -237,6 +237,7 @@ const tabSwitcherIndex = ref(0); const agentDriverUpdateCount = ref(0); const showHistory = ref(false); const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true"); +const isAiPanelMaximized = ref(false); const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true"); const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true"); const rightSidebarPanelRefs: Record = { @@ -826,6 +827,13 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) { } function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) { + if (panelId === "ai" && !open) { + isAiPanelMaximized.value = false; + } else if (open && panelId !== "ai" && isAiPanelMaximized.value) { + // Opening another right-side panel should make the hidden panel visible again + // instead of leaving it behind the maximized AI surface. + isAiPanelMaximized.value = false; + } const exclusive = settingsStore.isEditorSettingsLoaded && settingsStore.editorSettings.toolbarItems.exclusiveRightSidebarPanels; applyRightSidebarPanelState(transitionRightSidebarPanels(currentRightSidebarPanelState(), panelId, open, exclusive)); if (open) { @@ -847,6 +855,11 @@ function closeRightSidebarPanel(panelId: RightSidebarPanelId) { setRightSidebarPanelOpen(panelId, false); } +function toggleAiPanelMaximized() { + if (!showAiPanel.value) return; + isAiPanelMaximized.value = !isAiPanelMaximized.value; +} + function invokeWhenAiReady(invoke: (handle: AiAssistantHandle) => void) { if (aiAssistantRef.value) { invoke(aiAssistantRef.value); @@ -2847,7 +2860,7 @@ onUnmounted(() => { -
+
{
-
-
+
+
{ @insert-redis-command="(command: string) => routeAiRedisCommand(command, false)" @execute-redis-command="(command: string) => routeAiRedisCommand(command, true)" @open-explain-plan="onAiOpenExplainPlan" + @toggle-maximize="toggleAiPanelMaximized" @close="closeRightSidebarPanel('ai')" />
-
+
-
+
-
+
diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index deaaf1635e..f99fcfd689 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -23,7 +23,9 @@ import { HelpCircle, History, Loader2, + Maximize2, MessageSquarePlus, + Minimize2, Pencil, Plus, Replace, @@ -213,6 +215,7 @@ interface ChatMessage { const props = defineProps<{ tab?: QueryTab; connection?: ConnectionConfig; + maximized?: boolean; }>(); const emit = defineEmits<{ @@ -223,6 +226,7 @@ const emit = defineEmits<{ insertRedisCommand: [command: string]; executeRedisCommand: [command: string]; openExplainPlan: [sql: string]; + toggleMaximize: []; close: []; }>(); @@ -3127,7 +3131,11 @@ async function openExternalUrl(url: string) { - +
diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.maximize.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.maximize.spec.ts new file mode 100644 index 0000000000..308ea0112e --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.maximize.spec.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + +describe("AI assistant maximize control", () => { + it("exposes a parent-controlled maximize toggle with accessible labels", () => { + expect(source).toContain("maximized?: boolean;"); + expect(source).toContain("toggleMaximize: [];"); + expect(source).toContain(":title=\"props.maximized ? t('ai.restore') : t('ai.maximize')\""); + expect(source).toContain(":aria-label=\"props.maximized ? t('ai.restore') : t('ai.maximize')\""); + expect(source).toContain(':aria-pressed="props.maximized"'); + expect(source).toContain("@click=\"emit('toggleMaximize')\""); + expect(source).toContain('(); +const localStorageMock = { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, value), +}; + +function rect(left: number, width: number): DOMRect { + return { + bottom: 600, + height: 600, + left, + right: left + width, + top: 0, + width, + x: left, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +describe("usePanelResize", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", localStorageMock); + localStorageMock.clear(); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.unstubAllGlobals(); + }); + + it("lets the AI panel consume the available editor width beyond the legacy 800px cap", () => { + localStorage.setItem("dbx-ai-panel-width", "360"); + + const editor = document.createElement("div"); + const panel = document.createElement("div"); + const handle = document.createElement("div"); + panel.append(handle); + document.body.append(editor, panel); + + vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 700)); + vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(1000, 360)); + + const { aiPanelWidth, startAiPanelResize } = usePanelResize(); + handle.addEventListener("mousedown", startAiPanelResize); + handle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 1000 })); + document.dispatchEvent(new MouseEvent("mousemove", { clientX: -1000 })); + document.dispatchEvent(new MouseEvent("mouseup")); + + expect(aiPanelWidth.value).toBe(1060); + expect(localStorage.getItem("dbx-ai-panel-width")).toBe("1060"); + }); + + it("resizes from the flex-shrunk width after a wide panel is restored in a narrow window", () => { + localStorage.setItem("dbx-ai-panel-width", "1060"); + + const editor = document.createElement("div"); + const panel = document.createElement("div"); + const handle = document.createElement("div"); + panel.append(handle); + document.body.append(editor, panel); + + vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 0)); + vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(300, 700)); + + const { aiPanelWidth, startAiPanelResize } = usePanelResize(); + handle.addEventListener("mousedown", startAiPanelResize); + handle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 300 })); + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 400 })); + document.dispatchEvent(new MouseEvent("mouseup")); + + expect(aiPanelWidth.value).toBe(600); + expect(localStorage.getItem("dbx-ai-panel-width")).toBe("600"); + }); +}); diff --git a/apps/desktop/src/composables/usePanelResize.ts b/apps/desktop/src/composables/usePanelResize.ts index 9b4650597b..75f8fe091e 100644 --- a/apps/desktop/src/composables/usePanelResize.ts +++ b/apps/desktop/src/composables/usePanelResize.ts @@ -1,6 +1,20 @@ import { ref, type Ref } from "vue"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +const PANEL_MIN_WIDTH = 180; +const DEFAULT_PANEL_MAX_WIDTH = 800; +type PanelMaxWidth = number | ((handle: HTMLElement | null) => number); + +function availableAiPanelMaxWidth(handle: HTMLElement | null) { + const panel = handle?.parentElement; + const flexibleContent = panel?.previousElementSibling as HTMLElement | null; + if (!panel || !flexibleContent) return DEFAULT_PANEL_MAX_WIDTH; + + const panelRect = panel.getBoundingClientRect(); + const contentRect = flexibleContent.getBoundingClientRect(); + return Math.max(PANEL_MIN_WIDTH, panelRect.width + panelRect.left - contentRect.left); +} + export function usePanelResize() { const sidebarWidth = ref(Number(safeLocalStorageGet("dbx-sidebar-width")) || 260); const aiPanelWidth = ref(Number(safeLocalStorageGet("dbx-ai-panel-width")) || 360); @@ -8,15 +22,21 @@ export function usePanelResize() { const sqlLibraryWidth = ref(Number(safeLocalStorageGet("dbx-sql-library-width")) || 288); const sqlFilePanelWidth = ref(Number(safeLocalStorageGet("dbx-sql-file-panel-width")) || 288); - function startPanelResize(widthRef: Ref, storageKey: string, direction: "left" | "right") { + function startPanelResize(widthRef: Ref, storageKey: string, direction: "left" | "right", maxWidth: PanelMaxWidth = DEFAULT_PANEL_MAX_WIDTH) { return (e: MouseEvent) => { e.preventDefault(); const startX = e.clientX; - const startWidth = widthRef.value; + const resizeHandle = e.currentTarget as HTMLElement | null; + const resolvedMaxWidth = typeof maxWidth === "function" ? maxWidth(resizeHandle) : maxWidth; + const upperBound = Number.isFinite(resolvedMaxWidth) ? Math.max(PANEL_MIN_WIDTH, resolvedMaxWidth) : DEFAULT_PANEL_MAX_WIDTH; + const renderedWidth = resizeHandle?.parentElement?.getBoundingClientRect().width; + const requestedStartWidth = typeof renderedWidth === "number" && Number.isFinite(renderedWidth) && renderedWidth > 0 ? renderedWidth : widthRef.value; + const startWidth = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, requestedStartWidth)); + widthRef.value = startWidth; const onMouseMove = (ev: MouseEvent) => { const delta = ev.clientX - startX; - widthRef.value = Math.max(180, Math.min(800, startWidth + (direction === "right" ? delta : -delta))); + widthRef.value = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, startWidth + (direction === "right" ? delta : -delta))); }; const onMouseUp = () => { @@ -31,7 +51,7 @@ export function usePanelResize() { } const startSidebarResize = startPanelResize(sidebarWidth, "dbx-sidebar-width", "right"); - const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left"); + const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left", availableAiPanelMaxWidth); const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", "left"); const startSqlLibraryResize = startPanelResize(sqlLibraryWidth, "dbx-sql-library-width", "left"); const startSqlFilePanelResize = startPanelResize(sqlFilePanelWidth, "dbx-sql-file-panel-width", "left"); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2d0caf1337..c48a252229 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2476,6 +2476,8 @@ export default { append: "Append to Editor", apply: "Apply to Editor", clear: "Clear Chat", + maximize: "Maximize", + restore: "Restore", welcome: "Tell me what you'd like to query, and I'll write the SQL", requestFailed: "AI request failed", requestCancelled: "Request cancelled", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index bc36a8196b..ac87b6e2b8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2516,6 +2516,8 @@ export default withEnglishFallback({ append: "Agregar al editor", apply: "Aplicar al editor", clear: "Limpiar chat", + maximize: "Maximizar", + restore: "Restaurar", welcome: "Dime qué quieres consultar y escribiré el SQL", requestFailed: "La solicitud de AI falló", requestCancelled: "Solicitud de AI cancelada", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7103168ce8..bb3a64fef8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2410,6 +2410,8 @@ export default withEnglishFallback({ append: "Accoda all'Editor", apply: "Applica all'Editor", clear: "Cancella Chat", + maximize: "Massimizza", + restore: "Ripristina", welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL", requestFailed: "Richiesta AI non riuscita", requestCancelled: "Richiesta AI annullata", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a426e0ca72..698ba5dc6c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2549,6 +2549,8 @@ export default withEnglishFallback({ append: "エディタに追加", apply: "エディタに適用", clear: "チャットをクリア", + maximize: "最大化", + restore: "元に戻す", welcome: "クエリしたい内容を教えてください。SQLを作成します", requestFailed: "AIリクエストに失敗しました", requestCancelled: "AIリクエストがキャンセルされました", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6c1e9f9305..fd4c3cb711 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2307,6 +2307,8 @@ export default withEnglishFallback({ append: "편집기에 추가", apply: "편집기에 적용", clear: "채팅 지우기", + maximize: "최대화", + restore: "복원", welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요", requestFailed: "AI 요청 실패", requestCancelled: "AI 요청이 취소되었습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 36334abbfb..d846dba1de 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2518,6 +2518,8 @@ export default withEnglishFallback({ append: "Anexar ao Editor", apply: "Aplicar ao Editor", clear: "Limpar Conversa", + maximize: "Maximizar", + restore: "Restaurar", welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL", requestFailed: "Falha na solicitação de AI", requestCancelled: "Solicitação de AI cancelada", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9581564fea..10d25c14e2 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2400,6 +2400,8 @@ export default withEnglishFallback({ append: "追加到编辑器", apply: "应用到编辑器", clear: "清空对话", + maximize: "最大化", + restore: "还原", welcome: "聊聊你想查什么,我来写 SQL", requestFailed: "AI 请求失败", requestCancelled: "请求已取消", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d54362146b..62a2378c48 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2410,6 +2410,8 @@ export default withEnglishFallback({ append: "追加到編輯器", apply: "套用到編輯器", clear: "清空對話", + maximize: "最大化", + restore: "還原", welcome: "告訴我你想查詢什麼,我會寫出 SQL", requestFailed: "AI 請求失敗", requestCancelled: "AI 請求已取消", diff --git a/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts b/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts index 7b531ae812..b7fc4acee6 100644 --- a/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/rightSidebarPanels.spec.ts @@ -49,4 +49,13 @@ describe("right sidebar panel entry points", () => { } expect(appSource).not.toMatch(/watch\([\s\S]{0,180}toolbarItems\.(ai|history|sqlLibrary|sqlFileTree)[\s\S]{0,180}closeRightSidebarPanel/); }); + + it("keeps the maximized AI surface in the parent layout", () => { + expect(appSource).toContain("const isAiPanelMaximized = ref(false);"); + expect(appSource).toContain(':maximized="isAiPanelMaximized"'); + expect(appSource).toContain('@toggle-maximize="toggleAiPanelMaximized"'); + expect(appSource).toContain("isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full'"); + expect(appSource).toContain('v-show="!isAiPanelMaximized"'); + expect(functionSource("setRightSidebarPanelOpen", "toggleRightSidebarPanel")).toContain("isAiPanelMaximized.value = false;"); + }); });