From 6c2fd1183371fc279d71c1d24de87e58f8707534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 18 Sep 2026 09:56:26 +0200 Subject: [PATCH] fix(ui): restore the active project before session hydration Select the saved project as soon as its restored workspace tab is bound. Previously selection depended on conversation hydration finishing and settling the preserved tab, so slow requests or a competing capture settlement could leave the wrong project active. Keep the restored binding check and selection revision guard so a manual project choice during startup remains authoritative. Conversation hydration continues independently of project selection. Add a browser fixture exercising the real restore and capture hooks with delayed conversation requests for Electron and Tauri. Cover selection before hydration, persistence afterward, and user selection during restoration. Validated four browser cases, 27 targeted store tests, UI typecheck, and production UI build. --- .../src/lib/hooks/use-app-session-restore.ts | 9 +- .../browser/fixtures/project-tab-restore.tsx | 24 +++++ .../tests/browser/project-tab-restore.test.ts | 95 +++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/ui/tests/browser/fixtures/project-tab-restore.tsx create mode 100644 packages/ui/tests/browser/project-tab-restore.test.ts diff --git a/packages/ui/src/lib/hooks/use-app-session-restore.ts b/packages/ui/src/lib/hooks/use-app-session-restore.ts index c5f56204b..59fc0dbfa 100644 --- a/packages/ui/src/lib/hooks/use-app-session-restore.ts +++ b/packages/ui/src/lib/hooks/use-app-session-restore.ts @@ -128,10 +128,14 @@ async function restoreTabs(context: RestoreContext): Promise { if (!id) return null claimedIds.add(id) attachInstanceTab(id, { source: "restore" }) + const tabId = getInstanceAppTabId(id) + // Project selection belongs to the restored tab binding, not to the + // slower conversation hydration (which can also settle in capture). + if (match.tabIndex === snapshot.activeTabIndex + && capture.restoredTabIds()[match.tabIndex] === tabId) context.selectActive(tabId, true) const created = creation?.reused === false if (created) createdId = id try { - const tabId = getInstanceAppTabId(id) const isCurrentBinding = () => capture.hasRestoredTabBinding(match.tabIndex, tabId) if (!isCurrentBinding()) return id // Restore the exact saved session before the potentially expensive @@ -150,8 +154,7 @@ async function restoreTabs(context: RestoreContext): Promise { if (!unavailable || !isCurrentBinding()) return id if (creation?.requestId) await releaseRestoreCreatedInstance(id, creation.requestId) if (operationSignal.aborted) throw getAbortReason(operationSignal) - if (capture.settleRestoredTab(match.tabIndex, tabId, tabId, unavailable) - && match.tabIndex === snapshot.activeTabIndex) context.selectActive(tabId, true) + capture.settleRestoredTab(match.tabIndex, tabId, tabId, unavailable) } catch (error) { if (!existingId && creation?.requestId) { capture.settleRestoredTab(match.tabIndex, getInstanceAppTabId(id), null) diff --git a/packages/ui/tests/browser/fixtures/project-tab-restore.tsx b/packages/ui/tests/browser/fixtures/project-tab-restore.tsx new file mode 100644 index 000000000..2ea6cba79 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/project-tab-restore.tsx @@ -0,0 +1,24 @@ +import { createEffect, For } from "solid-js" +import { render } from "solid-js/web" +import { initializeClientState } from "../../../src/stores/client-state" +import { useAppSessionRestore } from "../../../src/lib/hooks/use-app-session-restore" +import { activeAppTabId, appTabs, ensureActiveAppTab, selectAppTab } from "../../../src/stores/app-tabs" +import { appSessionRestoreGateActive } from "../../../src/stores/app-session-restore-gate" + +await initializeClientState() +function Fixture() { + useAppSessionRestore() + createEffect(() => { + appTabs() + appSessionRestoreGateActive() + ensureActiveAppTab() + }) + return
+ {tab => } +
+} +render(() => , document.getElementById("root")!) diff --git a/packages/ui/tests/browser/project-tab-restore.test.ts b/packages/ui/tests/browser/project-tab-restore.test.ts new file mode 100644 index 000000000..9d6295d8d --- /dev/null +++ b/packages/ui/tests/browser/project-tab-restore.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict" +import { before, after, test } from "node:test" +import { fileURLToPath } from "node:url" +import { chromium, type Browser } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" + +let server: ViteDevServer, browser: Browser, url: string +before(async () => { + server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + plugins: [solid(), { name: "project-tab-restore", configureServer(s) { + s.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, + }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { await browser?.close(); await server?.close() }) + +for (const host of ["electron", "tauri"] as const) for (const userSelection of [false, true]) { +test(`${host} restores the active project before session hydration${userSelection ? " and respects a subsequent user selection" : ""}`, async () => { + const page = await browser.newPage() + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.addInitScript({ content: `{ + window.__CODENOMAD_RUNTIME_HOST__ = ${JSON.stringify(host)} + window.__CODENOMAD_WINDOW_CONTEXT__ = 'local' + window.EventSource = class extends EventTarget { close() {} } + const snapshot = { version: 1, revision: 1, savedAt: 1, layout: {}, session: { + activeTabIndex: 1, tabs: ['D:/first', 'D:/second'].map(folder => ({ + kind: 'workspace', folder, occurrence: 0, activeSessionId: 'saved-session', activeParentSessionId: 'saved-session', + drafts: {}, attachments: {}, scrollSnapshots: {}, unseenIdleSince: {}, generationRecovery: {}, + })) + } } + window.electronAPI = { + claimClientStateAccess: async () => true, + loadClientState: async () => ({ isPrimary: true, restoreEnabled: true, snapshot }), + saveClientState: async (_token, value) => { window.savedSnapshot = value; return true }, + } + if (${JSON.stringify(host)} === 'tauri') { + window.__TAURI_INTERNALS__ = { + transformCallback: () => 1, + invoke: async (command, args) => { + if (command === 'client_state_load') return { isPrimary: true, restoreEnabled: true, snapshot } + if (command === 'client_state_save') window.savedSnapshot = args.snapshot + return true + }, + } + window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} } + } + }` }) + let releaseSessions!: () => void + const sessionsReady = new Promise(resolve => { releaseSessions = resolve }) + const projects: any[] = [] + await page.route(/\/(?:api|workspaces)\//, async route => { + const request = route.request(), path = new URL(request.url()).pathname + if (!path.startsWith("/api/") && !path.startsWith("/workspaces/")) return route.continue() + let body: unknown = {} + if (path === "/api/workspaces") { + if (request.method() === "POST") { + const input = request.postDataJSON() + const id = input.path.endsWith("first") ? "first" : "second" + body = { id, path: input.path, status: "ready", port: 1234, proxyPath: `/workspaces/${id}/instance`, + binaryId: "fixture", binaryLabel: "fixture", createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), requestId: input.requestId } + projects.push(body) + } else body = projects + } else if (path.endsWith("/worktrees")) body = { worktrees: [] } + else if (path.includes("/instance/api/")) { + if (path.includes("/session")) { + await sessionsReady + if (path.endsWith("/saved-session")) return route.fulfill({ status: 404, json: { message: "Session no longer exists" } }) + } + body = [] + } + await route.fulfill({ json: body }) + }) + try { + await page.goto(url) + const selected = page.getByRole("tab", { name: "D:/second", exact: true }) + await selected.waitFor() + assert.equal(await selected.getAttribute("aria-selected"), "true", "project selection must not wait for its conversation requests") + if (userSelection) await page.getByRole("tab", { name: "D:/first", exact: true }).click() + releaseSessions() + await page.locator('[data-restoring="false"]').waitFor() + assert.equal(await page.getByRole("tab", { name: userSelection ? "D:/first" : "D:/second", exact: true }).getAttribute("aria-selected"), "true") + await page.waitForFunction(index => (window as any).savedSnapshot?.session?.activeTabIndex === index, userSelection ? 0 : 1) + assert.deepEqual(errors, []) + } finally { releaseSessions(); await page.close() } +}) +}