From 7e97e07c2d3a3e17f54f2f5c50cf5e7e793f7390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 3 Apr 2026 15:02:40 +0100 Subject: [PATCH] fix: guard against missing referencePanel in dockview focusOrAddPanel (#538) --- .../tests/settings/vscode-open-panel.spec.ts | 123 ++++++++++++++++++ .../web/lib/state/dockview-layout-builders.ts | 55 +++++--- 2 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/tests/settings/vscode-open-panel.spec.ts diff --git a/apps/web/e2e/tests/settings/vscode-open-panel.spec.ts b/apps/web/e2e/tests/settings/vscode-open-panel.spec.ts new file mode 100644 index 0000000000..44bfaf84fa --- /dev/null +++ b/apps/web/e2e/tests/settings/vscode-open-panel.spec.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +/** + * Resolve the code-server install directory. Checks the default kandev install + * path at ~/.kandev/tools/code-server/ (real HOME, not e2e temp HOME). + */ +function findCodeServerInstall(): string | null { + const home = os.homedir(); + const installDir = path.join(home, ".kandev", "tools", "code-server"); + if (!fs.existsSync(installDir)) return null; + + const entries = fs.readdirSync(installDir); + for (const entry of entries) { + const binPath = path.join(installDir, entry, "bin", "code-server"); + if (fs.existsSync(binPath)) return installDir; + } + return null; +} + +/** + * Seed a task + session via the API and navigate directly to the session page. + * Waits for the mock agent to complete its turn (idle input visible). + */ +async function seedTaskWithSession( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise<{ session: SessionPage; sessionId: string }> { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/t/${task.id}`); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + return { session, sessionId: task.session_id }; +} + +test.describe("VS Code open panel", () => { + test.describe.configure({ retries: 1 }); + + const codeServerDir = findCodeServerInstall(); + + test.beforeEach(async ({ backend }) => { + if (!codeServerDir) { + test.skip(true, "code-server not installed — skipping VS Code e2e tests"); + return; + } + + // Symlink the real code-server install into the e2e backend's HOME so + // ResolveBinary finds the pre-installed binary without re-downloading. + const targetDir = path.join(backend.tmpDir, ".kandev", "tools", "code-server"); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.symlinkSync(codeServerDir, targetDir); + } + }); + + /** + * Regression test for: "dockview: referencePanel 'chat' does not exist" + * + * When opening the embedded VS Code panel via the top-bar editor button, + * the dockview layout must not throw if the 'chat' reference panel is absent. + */ + test("opens VS Code panel via Open Editor without referencePanel error", async ({ + testPage, + apiClient, + seedData, + }) => { + // Track page errors to catch the dockview referencePanel exception + const pageErrors: Error[] = []; + testPage.on("pageerror", (err) => pageErrors.push(err)); + + const { session } = await seedTaskWithSession( + testPage, + apiClient, + seedData, + "VSCode Open Panel Test", + ); + + // Click the main "Open editor" button (the IconCode button in EditorsMenu). + // The tooltip reads "Open editor", so target the button inside that wrapper. + const editorBtn = testPage.locator("button:has(.tabler-icon-code)").first(); + await expect(editorBtn).toBeEnabled({ timeout: 10_000 }); + await editorBtn.click(); + + // Assert: VS Code tab appears in dockview + await expect(session.vscodeTab()).toBeVisible({ timeout: 10_000 }); + + // Assert: "Starting VS Code Server" progress text is visible while booting + await expect(testPage.getByText("Starting VS Code Server")).toBeVisible({ timeout: 30_000 }); + + // Assert: code-server iframe loads + await expect(session.vscodeIframe()).toBeVisible({ timeout: 90_000 }); + + // Assert: no dockview referencePanel errors were thrown + const referencePanelErrors = pageErrors.filter( + (e) => e.message.includes("referencePanel") && e.message.includes("does not exist"), + ); + expect(referencePanelErrors).toHaveLength(0); + }); +}); diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index 6a2ed90e38..a5ca6552d4 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -41,6 +41,23 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { return resolveGroupIds(api); } +/** + * Resolve a fallback group position when the intended reference is stale. + * Tries the well-known center group, then any non-sidebar group, then the + * first group. Returns undefined if no groups exist (drops the position). + */ +function fallbackGroupPosition(api: DockviewApi): { referenceGroup: string } | undefined { + const centerGroup = api.groups.find((g) => g.id === CENTER_GROUP); + if (centerGroup) return { referenceGroup: centerGroup.id }; + + const nonSidebarGroup = api.groups.find((g) => g.id !== SIDEBAR_GROUP); + if (nonSidebarGroup) return { referenceGroup: nonSidebarGroup.id }; + + if (api.groups.length > 0) return { referenceGroup: api.groups[0].id }; + + return undefined; +} + export function focusOrAddPanel( api: DockviewApi, options: AddPanelOptions & { id: string }, @@ -51,27 +68,31 @@ export function focusOrAddPanel( if (!quiet) existing.api.setActive(); return; } - // Guard: if the referenced group no longer exists (stale ID after layout - // transition), try the well-known center group, then the first non-sidebar - // group. Avoid falling back to the active panel's group because the user - // may have just clicked in the sidebar, which would place the new panel there. + // Guard: if the referenced group or panel no longer exists (stale ID after + // layout transition), fall back to a known group. Avoid the active panel's + // group because the user may have just clicked in the sidebar. const pos = options.position; if (pos && "referenceGroup" in pos) { const groupExists = api.groups.some((g) => g.id === pos.referenceGroup); if (!groupExists) { - const centerGroup = api.groups.find((g) => g.id === CENTER_GROUP); - const nonSidebarGroup = api.groups.find((g) => g.id !== SIDEBAR_GROUP); - if (centerGroup) { - options = { ...options, position: { ...pos, referenceGroup: centerGroup.id } }; - } else if (nonSidebarGroup) { - options = { ...options, position: { ...pos, referenceGroup: nonSidebarGroup.id } }; - } else if (api.groups.length > 0) { - options = { ...options, position: { ...pos, referenceGroup: api.groups[0].id } }; - } else { - options = Object.fromEntries( - Object.entries(options).filter(([k]) => k !== "position"), - ) as typeof options; - } + const fallback = fallbackGroupPosition(api); + options = fallback + ? { ...options, position: { ...pos, ...fallback } } + : (Object.fromEntries( + Object.entries(options).filter(([k]) => k !== "position"), + ) as typeof options); + } + } + + if (pos && "referencePanel" in pos) { + const refPanel = api.getPanel(pos.referencePanel as string); + if (!refPanel) { + const fallback = fallbackGroupPosition(api); + options = fallback + ? { ...options, position: fallback } + : (Object.fromEntries( + Object.entries(options).filter(([k]) => k !== "position"), + ) as typeof options); } }