From 2b30bf9a417f6ac18e41c06d22ca1957902bbff0 Mon Sep 17 00:00:00 2001 From: Mathis Blanc Date: Mon, 17 Aug 2026 11:40:07 +0200 Subject: [PATCH 1/4] fix: keep Explorer mounted during file navigation --- src/context_room.mjs | 35 ++++++++++++++++++++++---- test/context_room.test.mjs | 7 ++++-- test/e2e/layout-contract.spec.mjs | 41 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/context_room.mjs b/src/context_room.mjs index 5c1548e..76f328a 100644 --- a/src/context_room.mjs +++ b/src/context_room.mjs @@ -32209,6 +32209,18 @@ async function openContextHubProject(projectId, options = {}, requestedGeneratio if (review?.startupContext?.skillName) target.searchParams.set("startupSkill", review.startupContext.skillName); target.searchParams.set("explorer", (isExplorerDrawerViewport() || isExplorerCollapsed()) ? "collapsed" : "expanded"); if (options.filePath && IS_GLOBAL_CONTEXT_ROOM && state.workspaceIdentityReady) { + if (targetProjectId === state.activeProjectLocationId) { + if (state.page === "file" && state.selected === options.filePath) return; + await selectFile(options.filePath, { + reviewMode: true, + preserveViewer: state.page === "file" && Boolean(state.selected), + }); + if (state.page === "file" && state.selected === options.filePath) { + syncWorkspaceUrl({ push: true }); + recordWorkspaceDiagnostic("ready", "project-file-in-place"); + } + return; + } window.history.pushState(window.history.state, "", target); state.workspaceSyncedUrl = ""; await applyWorkspaceUrlState({ reason: "project-file", force: true }); @@ -36683,6 +36695,7 @@ async function selectFile(path, options = {}) { const profilingBoot = document.body.classList.contains("app-booting"); if (profilingBoot) state.bootMilestones.fileOpenStarted = Date.now() - state.bootStartedAt; const previousSelected = state.selected; + const preserveViewer = Boolean(options.preserveViewer && state.page === "file" && previousSelected && previousSelected !== path && el("viewer")?.children.length); state.selected = path; state.selectedReadOnly = Boolean(state.files.find((item) => item.path === path)?.readOnly); state.openingFilePath = path; @@ -36721,10 +36734,15 @@ async function selectFile(path, options = {}) { el("viewer").hidden = false; el("editor").hidden = true; el("editor").value = ""; - updateHeader(); - updateHistoryButtons(); - updateActionBanner(); - updatePreview(); + if (preserveViewer) { + el("viewer").inert = true; + el("viewer").setAttribute("aria-busy", "true"); + } else { + updateHeader(); + updateHistoryButtons(); + updateActionBanner(); + updatePreview(); + } if (IS_GLOBAL_CONTEXT_ROOM) { renderGlobalProjectExplorer(); void refreshExplorerRelatedForCurrentFile().catch((error) => setStatus(error.message)); @@ -36732,7 +36750,7 @@ async function selectFile(path, options = {}) { if (options.revealInExplorer || !document.querySelector('[data-file-path="' + cssEscape(path) + '"]')) renderFiles(); else updateExplorerSelectedFile(previousSelected, path); if (options.revealInExplorer && !isExplorerCollapsed()) scrollExplorerToPath(path); - renderViewer(); + if (!preserveViewer) renderViewer(); setStatus("opening..."); const fileRequest = readFileForOpen(path, { force: options.forceReload }); @@ -36754,6 +36772,11 @@ async function selectFile(path, options = {}) { state.fileLoadError = null; el("editor").value = data.content || ""; state.fileContentReadyPath = path; + if (preserveViewer) { + updateHeader(); + updateHistoryButtons(); + updatePreview(); + } renderViewer(); restorePersistedViewState(options.restoreViewState); setStatus("open · loading Git diff..."); @@ -42935,6 +42958,8 @@ function applySelectedTemplateToEditor(templateId) { } function renderViewer() { + el("viewer").inert = false; + el("viewer").removeAttribute("aria-busy"); const text = el("editor").value; const diff = state.selectedDiff || { changed: false, additions: 0, deletions: 0, patch: "", available: false }; const isStartupFile = Boolean(state.selectedStartupContext); diff --git a/test/context_room.test.mjs b/test/context_room.test.mjs index b295f84..b04e4b3 100644 --- a/test/context_room.test.mjs +++ b/test/context_room.test.mjs @@ -7323,6 +7323,9 @@ test("file opening renders loading and retry states instead of a blank document" assert.match(html, /kind: "hosted-review",[\s\S]*renderViewer\(\);/); assert.match(html, /state\.fileLoadError = \{ path, message: error\.message \|\| "Failed to open file\." \};/); assert.match(html, /updateExplorerSelectedFile\(previousSelected, path\)/); + assert.match(html, /targetProjectId === state\.activeProjectLocationId[\s\S]*preserveViewer: state\.page === "file"[\s\S]*syncWorkspaceUrl\(\{ push: true \}\)[\s\S]*project-file-in-place/); + assert.match(html, /const preserveViewer = Boolean\(options\.preserveViewer[\s\S]*el\("viewer"\)\.inert = true;[\s\S]*if \(!preserveViewer\) renderViewer\(\);/); + assert.match(html, /function renderViewer\(\) \{\s*el\("viewer"\)\.inert = false;\s*el\("viewer"\)\.removeAttribute\("aria-busy"\);/); assert.match(html, /function reconcileMissingSelectedFile\(\)/); assert.match(html, /function clearMissingSelectedFile\(stalePath = state\.selected\)/); assert.match(html, /function canReviewMissingFile\(path\)/); @@ -7613,9 +7616,9 @@ test("file opening shows content before secondary dependencies and keeps actions assert.match(selectFileFn, /const annotationsRequest = settleUiRequest\(loadAnnotationsForPath\(path\)\);/); assert.match(selectFileFn, /const diffRequest = settleUiRequest\(readDiffForOpen\(path, \{ force: options\.forceReload \}\)\);/); assert.match(selectFileFn, /const reviewBaseRequest = options\.reviewMode[\s\S]*settleUiRequest\(readSelectedReviewBase\(path\)\)/); - assert.match(selectFileFn, /const data = await fileRequest;[\s\S]*state\.fileContentReadyPath = path;\s*renderViewer\(\);[\s\S]*void annotationsRequest\.then/); + assert.match(selectFileFn, /const data = await fileRequest;[\s\S]*state\.fileContentReadyPath = path;[\s\S]*renderViewer\(\);[\s\S]*void annotationsRequest\.then/); assert.doesNotMatch(selectFileFn, /await annotationsRequest/); - assert.match(selectFileFn, /state\.fileContentReadyPath = path;\s*renderViewer\(\);\s*restorePersistedViewState\(options\.restoreViewState\);/); + assert.match(selectFileFn, /state\.fileContentReadyPath = path;[\s\S]*renderViewer\(\);\s*restorePersistedViewState\(options\.restoreViewState\);/); assert.match(selectFileFn, /setStatus\("open · loading Git diff\.\.\."\);/); assert.match(selectFileFn, /const \[diffResult, reviewBaseResult\] = await Promise\.all\(\[diffRequest, reviewBaseRequest\]\);/); assert.match(selectFileFn, /const \[diffResult, reviewBaseResult\] = await Promise\.all\(\[diffRequest, reviewBaseRequest\]\);[\s\S]*?finishOpen\(diffResult, reviewBaseResult\);/); diff --git a/test/e2e/layout-contract.spec.mjs b/test/e2e/layout-contract.spec.mjs index 3020e5d..6814f20 100644 --- a/test/e2e/layout-contract.spec.mjs +++ b/test/e2e/layout-contract.spec.mjs @@ -449,6 +449,47 @@ test("@layout rapid document switches settle Git review reads", async ({ page, b } }); +test("@layout Explorer file navigation keeps the workbench shell mounted", async ({ page }) => { + const data = fixture(); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`${data.origin}/?hub=1&project=${encodeURIComponent(data.projects.atlas.id)}&view=hub&explorer=expanded`); + await waitForBoot(page); + await ensureExplorerOpen(page); + + const docsFolder = page.locator('[data-global-project-folder="docs"]').first(); + if (await docsFolder.getAttribute("aria-expanded") !== "true") await docsFolder.click(); + await page.locator('[data-global-project-file="docs/README.md"]').first().click(); + await waitForOpenedFile(page, "docs/README.md"); + + const before = await page.evaluate(() => { + window.__persistentExplorerTree = document.querySelector(".global-project-folder-tree"); + window.__persistentWorkspaceDock = document.querySelector(".workspace-dock"); + return { + navigationGeneration: state.workspaceNavigationGeneration, + bootCount: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").bootCount || 0, + }; + }); + + await page.locator('[data-global-project-file="docs/operations.md"]').first().click(); + await waitForOpenedFile(page, "docs/operations.md"); + + await expect.poll(() => page.evaluate((expected) => ({ + explorerPreserved: window.__persistentExplorerTree === document.querySelector(".global-project-folder-tree"), + dockPreserved: window.__persistentWorkspaceDock === document.querySelector(".workspace-dock"), + navigationGeneration: state.workspaceNavigationGeneration, + bootCount: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").bootCount || 0, + navigationReason: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").lastNavigationReason || "", + viewerBusy: document.querySelector("#viewer")?.getAttribute("aria-busy") || "", + }), before)).toEqual({ + explorerPreserved: true, + dockPreserved: true, + navigationGeneration: before.navigationGeneration, + bootCount: before.bootCount, + navigationReason: "project-file-in-place", + viewerBusy: "", + }); +}); + test("@layout a fresh runtime subscription ignores replay already reflected by boot", async ({ page, browserName }) => { test.skip(browserName !== "webkit", "WebKit's event dispatch timing deterministically exercises the fresh-subscription replay boundary."); const data = fixture(); From 53f218e3d75085448152dc242eb88ef8066666e9 Mon Sep 17 00:00:00 2001 From: Mathis Blanc Date: Mon, 17 Aug 2026 11:43:09 +0200 Subject: [PATCH 2/4] release: 0.6.16 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00f7fcf..936ece8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "context-room", - "version": "0.6.15", + "version": "0.6.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "context-room", - "version": "0.6.15", + "version": "0.6.16", "license": "MIT", "dependencies": { "ajv": "^8.20.0", diff --git a/package.json b/package.json index 54da6ff..72b19b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context-room", - "version": "0.6.15", + "version": "0.6.16", "description": "Local-first documentation control room for AI-assisted projects.", "type": "module", "homepage": "https://www.npmjs.com/package/context-room", From ef788264e052286c9303d9f9ebf50977fd8b004d Mon Sep 17 00:00:00 2001 From: Mathis Blanc Date: Mon, 17 Aug 2026 11:51:41 +0200 Subject: [PATCH 3/4] test: make persistent navigation diagnostic race-safe --- test/e2e/layout-contract.spec.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/e2e/layout-contract.spec.mjs b/test/e2e/layout-contract.spec.mjs index 6814f20..9c82a11 100644 --- a/test/e2e/layout-contract.spec.mjs +++ b/test/e2e/layout-contract.spec.mjs @@ -464,6 +464,15 @@ test("@layout Explorer file navigation keeps the workbench shell mounted", async const before = await page.evaluate(() => { window.__persistentExplorerTree = document.querySelector(".global-project-folder-tree"); window.__persistentWorkspaceDock = document.querySelector(".workspace-dock"); + window.__workspaceDiagnosticReasons = []; + window.__workspaceDiagnosticObserver = new MutationObserver(() => { + const reason = JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").lastNavigationReason || ""; + if (reason) window.__workspaceDiagnosticReasons.push(reason); + }); + window.__workspaceDiagnosticObserver.observe(document.body, { + attributes: true, + attributeFilter: ["data-workspace-diagnostics"], + }); return { navigationGeneration: state.workspaceNavigationGeneration, bootCount: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").bootCount || 0, @@ -478,16 +487,17 @@ test("@layout Explorer file navigation keeps the workbench shell mounted", async dockPreserved: window.__persistentWorkspaceDock === document.querySelector(".workspace-dock"), navigationGeneration: state.workspaceNavigationGeneration, bootCount: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").bootCount || 0, - navigationReason: JSON.parse(document.body.dataset.workspaceDiagnostics || "{}").lastNavigationReason || "", + inPlaceDiagnosticObserved: window.__workspaceDiagnosticReasons.includes("project-file-in-place"), viewerBusy: document.querySelector("#viewer")?.getAttribute("aria-busy") || "", }), before)).toEqual({ explorerPreserved: true, dockPreserved: true, navigationGeneration: before.navigationGeneration, bootCount: before.bootCount, - navigationReason: "project-file-in-place", + inPlaceDiagnosticObserved: true, viewerBusy: "", }); + await page.evaluate(() => window.__workspaceDiagnosticObserver?.disconnect()); }); test("@layout a fresh runtime subscription ignores replay already reflected by boot", async ({ page, browserName }) => { From 48af5561f1e05b28ff158d4962594c7dfc2c3feb Mon Sep 17 00:00:00 2001 From: Mathis Blanc Date: Mon, 17 Aug 2026 12:08:45 +0200 Subject: [PATCH 4/4] fix: settle root-change diagnostics after boot --- src/context_room.mjs | 5 ++++- test/context_room.test.mjs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/context_room.mjs b/src/context_room.mjs index 76f328a..b3fce2c 100644 --- a/src/context_room.mjs +++ b/src/context_room.mjs @@ -36078,7 +36078,10 @@ function acceptContextRoomRoot(nextRoot) { if (IS_GLOBAL_CONTEXT_ROOM) { state.root = nextRoot; state.lastAgentCommandId = readLastAgentCommandId(); - recordWorkspaceDiagnostic("refreshing", "selected-project-root-changed"); + recordWorkspaceDiagnostic( + document.body.classList.contains("app-booting") ? "refreshing" : "ready", + "selected-project-root-changed", + ); return; } handleContextRoomProjectChange({ reason: "server-root-changed" }); diff --git a/test/context_room.test.mjs b/test/context_room.test.mjs index b04e4b3..477bfe1 100644 --- a/test/context_room.test.mjs +++ b/test/context_room.test.mjs @@ -7199,6 +7199,7 @@ test("browser refresh restores the last Context Room page", () => { assert.match(html, /const pathFilters = rawPathFilters\.filter\(\(filter\) => state\.files\.some\(\(file\) => pathMatchesFilter\(file\.path, filter\)\)\);/); assert.match(html, /el\("search"\)\.value = persisted\.searchText \|\| folderFilterSearchQuery\(state\.pathFilters\);/); assert.match(html, /function acceptContextRoomRoot\(nextRoot\)[\s\S]*if \(!state\.root\) \{[\s\S]*state\.root = nextRoot;[\s\S]*if \(state\.root === nextRoot\) return;[\s\S]*if \(IS_GLOBAL_CONTEXT_ROOM\) \{[\s\S]*state\.root = nextRoot;[\s\S]*handleContextRoomProjectChange\(\{ reason: "server-root-changed" \}\);/); + assert.match(html, /function acceptContextRoomRoot\(nextRoot\)[\s\S]*document\.body\.classList\.contains\("app-booting"\) \? "refreshing" : "ready",[\s\S]*"selected-project-root-changed"/); assert.match(html, /acceptContextRoomRoot\(data\.root\);/); assert.match(html, /const hasDirectContextHubTarget = Boolean\(requestedReviewFile \|\| requestedHubCard \|\| requestedStartupOrder \|\| state\.sharedContext\?\.mode === "review"\);/); assert.match(html, /if \(options\.initial && IS_GLOBAL_CONTEXT_ROOM\) \{[\s\S]*await state\.contextHubReadyPromise;[\s\S]*renderGlobalProjectExplorer\(\);/);