feat(ai): add resizable maximized chat panel

This commit is contained in:
Xinbeok authored and GitHub committed 2026-08-23 18:29:35 +08:00
1 parent add44cf39e
commit 2927e01591
14 files changed
+182 -11

No files matched your search

+25 -6
View File
@@ -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<RightSidebarPanelId, typeof showAiPanel> = {
@@ -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(() => {
</Button>
</div>
<div :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div class="h-full flex flex-col min-w-0">
<AppTabBar
ref="appTabBarRef"
@@ -3015,14 +3028,19 @@ onUnmounted(() => {
</div>
</div>
<div v-if="showAiPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: aiPanelWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
<div
v-if="showAiPanel"
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full']"
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
>
<div v-if="!isAiPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<AiAssistant
v-if="aiPanelReady"
ref="aiAssistantRef"
:tab="activeTab"
:connection="activeConnection"
:maximized="isAiPanelMaximized"
@replace-sql="onAiReplaceSql"
@execute-sql="onAiExecuteSql"
@temp-run-sql="onAiTempRunSql"
@@ -3030,26 +3048,27 @@ 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')"
/>
</div>
</div>
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div v-if="showHistory" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
</div>
</div>
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div v-if="showSqlLibraryPanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
</div>
</div>
<div v-if="showSqlFilePanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
<div v-if="showSqlFilePanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlFilePanelResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
@@ -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) {
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
<Trash2 class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
<Button variant="ghost" size="icon" class="h-6 w-6" :title="props.maximized ? t('ai.restore') : t('ai.maximize')" :aria-label="props.maximized ? t('ai.restore') : t('ai.maximize')" :aria-pressed="props.maximized" @click="emit('toggleMaximize')">
<Minimize2 v-if="props.maximized" class="h-3.5 w-3.5" />
<Maximize2 v-else class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('common.close')" :aria-label="t('common.close')" @click="emit('close')">
<X class="h-3.5 w-3.5" />
</Button>
</div>
@@ -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('<Minimize2 v-if="props.maximized"');
expect(source).toContain("<Maximize2 v-else");
});
});
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePanelResize } from "@/composables/usePanelResize";
const storage = new Map<string, string>();
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");
});
});
+24 -4
View File
@@ -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<number>, storageKey: string, direction: "left" | "right") {
function startPanelResize(widthRef: Ref<number>, 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");
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -2549,6 +2549,8 @@ export default withEnglishFallback({
append: "エディタに追加",
apply: "エディタに適用",
clear: "チャットをクリア",
maximize: "最大化",
restore: "元に戻す",
welcome: "クエリしたい内容を教えてください。SQLを作成します",
requestFailed: "AIリクエストに失敗しました",
requestCancelled: "AIリクエストがキャンセルされました",
+2
View File
@@ -2307,6 +2307,8 @@ export default withEnglishFallback({
append: "편집기에 추가",
apply: "편집기에 적용",
clear: "채팅 지우기",
maximize: "최대화",
restore: "복원",
welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요",
requestFailed: "AI 요청 실패",
requestCancelled: "AI 요청이 취소되었습니다",
+2
View File
@@ -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",
+2
View File
@@ -2400,6 +2400,8 @@ export default withEnglishFallback({
append: "追加到编辑器",
apply: "应用到编辑器",
clear: "清空对话",
maximize: "最大化",
restore: "还原",
welcome: "聊聊你想查什么,我来写 SQL",
requestFailed: "AI 请求失败",
requestCancelled: "请求已取消",
+2
View File
@@ -2410,6 +2410,8 @@ export default withEnglishFallback({
append: "追加到編輯器",
apply: "套用到編輯器",
clear: "清空對話",
maximize: "最大化",
restore: "還原",
welcome: "告訴我你想查詢什麼,我會寫出 SQL",
requestFailed: "AI 請求失敗",
requestCancelled: "AI 請求已取消",
@@ -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;");
});
});