From 26ab6130e0b030f4c65f4df40a37d999c0ed4fbf Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 13 Oct 2026 19:56:38 +0900 Subject: [PATCH] fix: navigate before loading page data --- docs/principle/ux-design.md | 2 + .../browser-tests/deferred_navigation_test.ts | 496 +++ tools/web-ux/deno.json | 2 +- web/workspace/deno.json | 2 +- .../console/worker-console.ui.test.ts | 76 +- .../header/WorkspaceBreadcrumbs.svelte | 17 +- .../workspace/navigation/DeferredPage.svelte | 79 + .../navigation/deferred-page.browser.spec.ts | 143 + .../lib/workspace/navigation/deferred.test.ts | 96 + .../src/lib/workspace/navigation/deferred.ts | 104 + .../navigation/observe-deferred.svelte.ts | 43 + .../navigation/refresh.browser.spec.ts | 37 + .../src/lib/workspace/navigation/refresh.ts | 20 + .../workspace/sidebar/GlobalSidebar.svelte | 2 +- .../workspace/sidebar/WorkspaceSidebar.svelte | 2 +- .../sidebar/workspace-switcher.test.ts | 7 +- .../tickets/merge-request-resources.test.ts | 4 +- .../workspace/tickets/ticket-panel.test.ts | 2 +- web/workspace/src/routes/+layout.svelte | 9 +- web/workspace/src/routes/+layout.ts | 31 +- web/workspace/src/routes/+page.svelte | 196 +- web/workspace/src/routes/+page.ts | 5 +- web/workspace/src/routes/PageContent.svelte | 194 ++ .../src/routes/sidebar-fold.browser.spec.ts | 6 +- .../src/routes/w/[workspaceId]/+layout.svelte | 42 +- .../src/routes/w/[workspaceId]/+layout.ts | 7 +- .../src/routes/w/[workspaceId]/+page.svelte | 57 +- .../routes/w/[workspaceId]/PageContent.svelte | 55 + .../w/[workspaceId]/home.browser.spec.ts | 4 +- .../w/[workspaceId]/memory/+page.svelte | 574 +--- .../routes/w/[workspaceId]/memory/+page.ts | 5 +- .../w/[workspaceId]/memory/PageContent.svelte | 576 ++++ .../memory/[subjectId]/+page.svelte | 800 +---- .../[workspaceId]/memory/[subjectId]/+page.ts | 5 +- .../memory/[subjectId]/PageContent.svelte | 810 +++++ .../[subjectId]/[memoryId]/+page.svelte | 666 +--- .../memory/[subjectId]/[memoryId]/+page.ts | 5 +- .../[subjectId]/[memoryId]/PageContent.svelte | 668 ++++ .../memory/memory-page.browser.spec.ts | 59 +- .../[workspaceId]/memory/staging/+page.svelte | 305 +- .../w/[workspaceId]/memory/staging/+page.ts | 5 +- .../memory/staging/PageContent.svelte | 307 ++ .../[workspaceId]/merge-requests/+page.svelte | 62 +- .../w/[workspaceId]/merge-requests/+page.ts | 6 +- .../merge-requests/PageContent.svelte | 64 + .../[mergeRequestId]/+page.svelte | 129 +- .../merge-requests/[mergeRequestId]/+page.ts | 6 +- .../[mergeRequestId]/PageContent.svelte | 131 + .../w/[workspaceId]/objectives/+page.svelte | 51 +- .../w/[workspaceId]/objectives/+page.ts | 5 +- .../objectives/PageContent.svelte | 50 + .../objectives/[objectiveId]/+page.svelte | 71 +- .../objectives/[objectiveId]/+page.ts | 5 +- .../[objectiveId]/PageContent.svelte | 70 + .../w/[workspaceId]/orchestrator/+page.svelte | 88 +- .../w/[workspaceId]/orchestrator/+page.ts | 5 +- .../orchestrator/PageContent.svelte | 88 + .../repositories/[repositoryKey]/+page.svelte | 261 +- .../repositories/[repositoryKey]/+page.ts | 5 +- .../[repositoryKey]/PageContent.svelte | 262 ++ .../w/[workspaceId]/settings/+layout.svelte | 4 +- .../w/[workspaceId]/settings/+page.svelte | 20 +- .../[workspaceId]/settings/PageContent.svelte | 19 + .../settings/configuration/+page.svelte | 30 +- .../settings/configuration/PageContent.svelte | 29 + .../settings/profiles/+page.svelte | 80 +- .../settings/profiles/PageContent.svelte | 79 + .../settings/repositories/+page.svelte | 150 +- .../settings/repositories/+page.ts | 5 +- .../settings/repositories/PageContent.svelte | 151 + .../settings/repository-access/+page.svelte | 879 +---- .../settings/repository-access/+page.ts | 5 +- .../repository-access/PageContent.svelte | 895 +++++ .../repository-access.browser.spec.ts | 115 +- .../settings/runtimes/+page.svelte | 437 +-- .../[workspaceId]/settings/runtimes/+page.ts | 5 +- .../settings/runtimes/PageContent.svelte | 436 +++ .../runtimes/[runtimeId]/+page.svelte | 663 +--- .../settings/runtimes/[runtimeId]/+page.ts | 5 +- .../runtimes/[runtimeId]/PageContent.svelte | 662 ++++ .../[runtimeId]/workdirs/+page.svelte | 261 +- .../runtimes/[runtimeId]/workdirs/+page.ts | 5 +- .../[runtimeId]/workdirs/PageContent.svelte | 259 ++ .../runtime-enrollment.browser.spec.ts | 32 +- .../settings/settings.browser.spec.ts | 6 +- .../w/[workspaceId]/tickets/+page.svelte | 374 +-- .../routes/w/[workspaceId]/tickets/+page.ts | 5 +- .../[workspaceId]/tickets/PageContent.svelte | 376 +++ .../tickets/[ticketId]/+page.svelte | 769 +---- .../[workspaceId]/tickets/[ticketId]/+page.ts | 5 +- .../tickets/[ticketId]/PageContent.svelte | 757 +++++ .../w/[workspaceId]/workers/+page.svelte | 482 +-- .../routes/w/[workspaceId]/workers/+page.ts | 5 +- .../[workspaceId]/workers/PageContent.svelte | 483 +++ .../workers/[workerRef]/+page.svelte | 37 +- .../workers/[workerRef]/+page.ts | 5 +- .../workers/[workerRef]/PageContent.svelte | 32 + .../workers/[workerRef]/console/+page.svelte | 2966 +---------------- .../workers/[workerRef]/console/+page.ts | 5 +- .../[workerRef]/console/PageContent.svelte | 2961 ++++++++++++++++ .../console/console-display.browser.spec.ts | 2 +- .../w/[workspaceId]/workers/new/+page.svelte | 638 +--- .../workers/new/PageContent.svelte | 640 ++++ .../new/empty-workspace.browser.spec.ts | 4 +- .../workers/workers-page.browser.spec.ts | 21 +- ...orkers-restore-integration.browser.spec.ts | 3 +- ...workspace-layout-lifecycle.browser.spec.ts | 9 +- .../routes/workspace-catalog.browser.spec.ts | 4 +- web/workspace/test/composer-history.test.ts | 2 +- web/workspace/test/objective-pages.test.ts | 4 +- web/workspace/test/repositories/ui.test.ts | 4 +- .../test/repository-access/loader.test.ts | 43 +- .../test/repository-access/structure.test.ts | 2 +- .../test/repository-ssh-connection-ui.test.ts | 2 +- .../test/ticket-detail-route-reuse.test.ts | 2 +- .../tests/console-spinner-source.test.ts | 29 +- .../tests/console-view-mode-source.test.ts | 18 +- .../tests/runtime-management-source.test.ts | 10 +- web/workspace/tests/workspace-model.test.ts | 4 +- 119 files changed, 12748 insertions(+), 11071 deletions(-) create mode 100644 tools/web-ux/browser-tests/deferred_navigation_test.ts create mode 100644 web/workspace/src/lib/workspace/navigation/DeferredPage.svelte create mode 100644 web/workspace/src/lib/workspace/navigation/deferred-page.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/navigation/deferred.test.ts create mode 100644 web/workspace/src/lib/workspace/navigation/deferred.ts create mode 100644 web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts create mode 100644 web/workspace/src/lib/workspace/navigation/refresh.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/navigation/refresh.ts create mode 100644 web/workspace/src/routes/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/staging/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/merge-requests/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/orchestrator/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/configuration/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/profiles/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/repositories/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/repository-access/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/runtimes/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/tickets/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/new/PageContent.svelte diff --git a/docs/principle/ux-design.md b/docs/principle/ux-design.md index 878ad0d64..12967bc81 100644 --- a/docs/principle/ux-design.md +++ b/docs/principle/ux-design.md @@ -16,6 +16,8 @@ Workspace Webは、Workspaceの確定した状態を確認し、注意が必要 存在しないWorkspace、閲覧できないWorkspace、読込みに失敗したWorkspaceは区別し、別のWorkspaceへ暗黙に切り替えない。読取り専用の場合も、利用できる閲覧機能を残す。 +ページ遷移はAPI応答を待たずに遷移先と読込み中表示を出す。旧ページを新しいURLの内容として残さず、遅れて届いた旧対象の応答で表示やcanonical遷移を上書きしない。同じ対象の再取得は別の更新中表示にし、Consoleの下書きなど編集中の内容を破棄しない。権限が必要な操作は確認済みのデータが届くまで表示しない。 + ## ページの目的 各routeの主要目的は、「Ticketを選ぶ」「Workerを確認する」「Repositoryのアクセスを設定する」のように、動詞と対象を一つずつ使って表現できるものにする。 diff --git a/tools/web-ux/browser-tests/deferred_navigation_test.ts b/tools/web-ux/browser-tests/deferred_navigation_test.ts new file mode 100644 index 000000000..d945355f3 --- /dev/null +++ b/tools/web-ux/browser-tests/deferred_navigation_test.ts @@ -0,0 +1,496 @@ +/// +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { type Browser, chromium, type Page, type Request, type Route } from "npm:playwright@1.59.1"; +import { loadScenario } from "../src/scenario.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +// Explicit browser boundary: production static assets + synthetic loopback API. +// No real Backend auth, Worker execution, or application test hooks. +// The caller supplies the built workspace via the standard suite layout; for isolated +// runs, copy this suite into a tree whose web/workspace/build points at isolated assets. +// This suite NEVER runs a build, mutates assets, or falls back to another static root. +const staticRoot = new URL("../../../web/workspace/build/", import.meta.url).pathname; +const output = new URL("../../../.yoi/dev/navigation-visual/", import.meta.url).pathname; +const phase = "after"; +const listPath = "/w/tickets/tickets"; +const detailPath = "/w/tickets/tickets/T-2-read-only-investigation"; +const otherDetailPath = "/w/tickets/tickets/T-3-independent-review-before-merge"; + +async function withFixture(run: (browser: Browser, base: string) => Promise) { + // A missing build is an explicit failure; no alternate root or implicit build. + await Deno.stat(`${staticRoot}index.html`); + const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; + const scenario = await loadScenario(scenarioPath); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + scenario.processes![0].args![4] = String(port); + scenario.processes![0].args![5] = staticRoot; + scenario.processes![0].readyUrl = `${base}/health`; + const processes = await startOwnedProcesses( + scenario.processes!, + scenarioPath, + `${output}${phase}/logs/${port}`, + [], + ); + let browser: Browser | null = null; + try { + browser = await chromium.launch({ headless: true }); + await run(browser, base); + } finally { + await browser?.close(); + assertEquals(await stopOwnedProcesses(processes), [], "owned fixture cleanup"); + } +} + +async function bounded(promise: Promise): Promise { + let timer: number | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Response gate timed out")), 15_000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +// Fetch the real synthetic response, but do not deliver it to the browser until release. +// held means response bytes exist in Playwright, NOT requestfinished in the browser. +async function responseGate( + page: Page, + pattern: string | RegExp, + matches: (request: Request) => boolean = () => true, + replacement?: { status: number; body: string; contentType: string }, + transform?: (body: Record) => unknown, +) { + const held = Promise.withResolvers(); + const releaseSignal = Promise.withResolvers(); + const delivered = Promise.withResolvers(); + let closed = true; + let intercepted = false; + let browserFinished = false; + let gatedRequest: Request | undefined; + const onFinished = (request: Request) => { + if (request === gatedRequest) browserFinished = true; + }; + page.on("requestfinished", onFinished); + const handler = async (route: Route) => { + if (intercepted || !matches(route.request())) return await route.fallback(); + intercepted = true; + gatedRequest = route.request(); + try { + const response = replacement ? undefined : await route.fetch(); + const json = transform && response ? transform(await response.json()) : undefined; + held.resolve(); + await releaseSignal.promise; + if (replacement) await route.fulfill(replacement); + else await route.fulfill({ response, ...(json === undefined ? {} : { json }) }); + delivered.resolve(); + } catch (error) { + held.reject(error); + delivered.reject(error); + } + }; + await page.route(pattern, handler); + // Attach handlers immediately so cancellation during teardown isn't an unhandled rejection. + void held.promise.catch(() => {}); + void delivered.promise.catch(() => {}); + return { + held: () => bounded(held.promise), + assertClosed() { + assert(intercepted, "API response must be intercepted first"); + assert(closed, "response gate must remain CLOSED during navigation assertions"); + assertEquals(browserFinished, false, "browser must not have finished the held request"); + }, + release() { + closed = false; + releaseSignal.resolve(); + }, + delivered: () => bounded(delivered.promise), + async dispose() { + closed = false; + releaseSignal.resolve(); + await page.unroute(pattern, handler); + page.off("requestfinished", onFinished); + }, + }; +} +type Gate = Awaited>; + +async function loading(page: Page, gate: Gate, path: string, title: RegExp, updating = false) { + await gate.held(); + gate.assertClosed(); + await page.waitForURL((url) => url.pathname + url.search === path, { timeout: 5_000 }); + await page.getByRole("heading", { name: title, level: 1 }).waitFor({ timeout: 5_000 }); + await page.getByRole("status").filter({ hasText: updating ? /^Updating…$/ : /Loading/ }) + .first().waitFor({ timeout: 5_000 }); + gate.assertClosed(); +} + +// Synthetic links exercise the public SvelteKit delegated link handler, not a full reload. +// Used only where the fixture has no product link (cross-workspace and stale slug scenarios). +async function navigate(page: Page, path: string) { + await page.evaluate((path) => { + const link = document.createElement("a"); + link.href = path; + link.dataset.sveltekitPreloadData = "off"; + document.body.append(link); + link.click(); + link.remove(); + }, path); +} +async function clickTickets(page: Page) { + // Actual product sidebar link, even when the mobile sidebar is folded. + await page.locator("a.sidebar-link").filter({ hasText: /^Tickets$/ }) + .evaluate((link) => (link as HTMLAnchorElement).click()); +} +async function paint(page: Page) { + await page.evaluate(() => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ); +} +async function capture(page: Page, name: string, gate?: Gate) { + gate?.assertClosed(); + await paint(page); + const directory = `${output}${phase}`; + await Deno.mkdir(directory, { recursive: true }); + await page.screenshot({ path: `${directory}/${name}.png`, fullPage: true }); + // Safe, fixture-only evidence. Deliberately no cookies, storage, headers, trace or HAR. + const context = await page.evaluate(() => ({ + path: location.pathname + location.search, + title: document.title, + headings: [...document.querySelectorAll("h1")].map((node) => node.textContent), + statuses: [...document.querySelectorAll('[role="status"]')].map((node) => node.textContent), + liveRegions: [...document.querySelectorAll('[aria-live="polite"]')].map((node) => + node.textContent + ), + alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent), + viewport: { width: innerWidth, height: innerHeight }, + theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", + pageOverflow: document.documentElement.scrollWidth > innerWidth, + })); + assertEquals(context.pageOverflow, false, "navigation state must not overflow viewport"); + await Deno.writeTextFile( + `${directory}/${name}.json`, + JSON.stringify( + { + ...context, + responseGate: gate ? "closed" : "resolved", + staticRoot, + boundary: "synthetic loopback fixture; no real Backend authentication", + }, + null, + 2, + ), + ); + gate?.assertClosed(); +} +async function rows(page: Page) { + await page.locator("a.ticket-list-row").first().waitFor(); +} +async function search(page: Page, query: string) { + await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(query); + await page.getByRole("button", { name: "Search", exact: true }).click(); +} +const queryPattern = "**/api/w/tickets/tickets/query"; + +Deno.test("immediate navigation: Orchestrator -> Tickets -> detail before responses, visual matrix", async () => { + await withFixture(async (browser, base) => { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + colorScheme, + viewport: { width, height: 900 }, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto(`${base}/w/tickets/orchestrator`); + await page.locator(".orchestrator-assignment").first().waitFor(); + const listGate = await responseGate(page, queryPattern); + try { + await clickTickets(page); + await loading(page, listGate, listPath, /^Tickets$/); + assertEquals(await page.locator(".orchestrator-assignment").count(), 0); + await capture(page, `${colorScheme}-${width}-tickets-gated`, listGate); + listGate.release(); + await rows(page); + await capture(page, `${colorScheme}-${width}-tickets-resolved`); + } finally { + await listGate.dispose(); + } + const detailGate = await responseGate(page, "**/api/w/tickets/tickets/T-2"); + try { + await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click(); + await loading(page, detailGate, detailPath, /^Ticket$/); + assertEquals(await page.locator("a.ticket-list-row").count(), 0); + assertEquals( + await page.getByRole("button", { name: "Release assignment", exact: true }).count(), + 0, + ); + await capture(page, `${colorScheme}-${width}-detail-gated`, detailGate); + detailGate.release(); + await page.getByRole("heading", { name: "Read-only investigation", exact: true }) + .waitFor(); + await capture(page, `${colorScheme}-${width}-detail-resolved`); + } finally { + await detailGate.dispose(); + } + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + }); +}); + +Deno.test("immediate navigation: query and back/forward commit URLs with response gates closed", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}${listPath}`); + await rows(page); + const open = await responseGate(page, queryPattern); + try { + await search(page, "state:closed"); + await loading(page, open, `${listPath}?q=state%3Aclosed`, /^Tickets$/, true); + open.release(); + await page.locator(".ticket-list-key").filter({ hasText: /^T-4$/ }).waitFor(); + } finally { + await open.dispose(); + } + for (const direction of ["back", "forward"] as const) { + const gate = await responseGate(page, queryPattern); + try { + // Don't await goBack/goForward's optional load lifecycle while API is gated. + await page.evaluate( + (direction) => direction === "back" ? history.back() : history.forward(), + direction, + ); + const path = direction === "back" ? listPath : `${listPath}?q=state%3Aclosed`; + await loading(page, gate, path, /^Tickets$/, true); + await capture(page, `query-${direction}-gated`, gate); + gate.release(); + await page.getByRole("searchbox", { name: "Search tickets", exact: true }) + .waitFor(); + await page.waitForFunction( + (value) => + (document.querySelector('input[type="search"]') as HTMLInputElement)?.value === value, + direction === "back" ? "state:open" : "state:closed", + ); + await rows(page); + if (direction === "forward") { + assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]); + } + } finally { + await gate.dispose(); + } + } + }); +}); + +Deno.test("immediate navigation: rapid A -> B rejects late A data and stale canonical redirects", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}${listPath}`); + await rows(page); + // First canonical A (late data), then an obsolete slug A (late canonical redirect). + for (const pathA of [detailPath, "/w/tickets/tickets/T-2-obsolete-title"]) { + const gateA = await responseGate(page, "**/api/w/tickets/tickets/T-2"); + const gateB = await responseGate(page, "**/api/w/tickets/tickets/T-3"); + try { + await navigate(page, pathA); + await loading(page, gateA, pathA, /^Ticket$/); + await navigate(page, otherDetailPath); + await loading(page, gateB, otherDetailPath, /^Ticket$/); + gateA.assertClosed(); + gateB.release(); + await page.getByRole("heading", { name: "Independent review before merge", exact: true }) + .waitFor(); + const response = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/w/tickets/tickets/T-2" + ); + gateA.release(); + await response; + await gateA.delivered(); + await paint(page); + assertEquals(new URL(page.url()).pathname, otherDetailPath); + assert( + await page.getByRole("heading", { + name: "Independent review before merge", + exact: true, + }).isVisible(), + ); + assertEquals( + await page.getByRole("heading", { name: "Read-only investigation", exact: true }) + .count(), + 0, + ); + await capture( + page, + pathA === detailPath + ? "late-old-response-resolved" + : "stale-canonical-redirect-resolved", + ); + } finally { + await gateA.dispose(); + await gateB.dispose(); + } + await navigate(page, listPath); + await rows(page); + } + }); +}); + +Deno.test("immediate navigation: slow workspace switch clears old identity and permission projection", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + // Same existing fixture server, distinct identities and explicit opposite permissions. + await page.route("**/api/w/*/workspace", async (route) => { + const response = await route.fetch(); + const body = await response.json(); + const old = body.workspace_id === "tickets"; + body.display_name = old ? "Old privileged workspace" : "New read-only workspace"; + body.permissions = { + manage_repositories: old, + manage_secrets: old, + manage_runtimes: old, + delete_workspace: old, + }; + await route.fulfill({ response, json: body }); + }); + await page.goto(`${base}${detailPath}`); + await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor(); + await page.getByText("Old privileged workspace", { exact: true }).first().waitFor(); + assert( + await page.getByRole("button", { name: "Release assignment", exact: true }).isVisible(), + ); + // route.fetch bypasses other Playwright handlers: transform this held response too. + const gate = await responseGate( + page, + "**/api/w/tickets-readonly/workspace", + () => true, + undefined, + (body) => ({ ...body, display_name: "New read-only workspace" }), + ); + const target = detailPath.replace("/w/tickets/", "/w/tickets-readonly/"); + try { + await navigate(page, target); + await loading(page, gate, target, /^Workspace$/); + assertEquals(await page.getByText("Old privileged workspace", { exact: true }).count(), 0); + assertEquals( + await page.getByRole("button", { name: "Release assignment", exact: true }).count(), + 0, + ); + assertEquals( + await page.locator('a[href="/w/tickets/settings"], a[href="/w/tickets"]').count(), + 0, + ); + await capture(page, "workspace-switch-gated", gate); + gate.release(); + await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor(); + await page.getByText("New read-only workspace", { exact: true }).first().waitFor(); + assertEquals( + await page.getByRole("button", { name: "Release assignment", exact: true }).count(), + 0, + ); + await capture(page, "workspace-switch-resolved"); + } finally { + await gate.dispose(); + } + }); +}); + +Deno.test("immediate navigation: cold load and unrelated global catalog response cannot block destination", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + const catalog = await responseGate(page, /\/api\/workspaces(?:\?|$)/); + const tickets = await responseGate(page, queryPattern); + try { + await page.goto(`${base}${listPath}`, { waitUntil: "domcontentloaded" }); + await catalog.held(); + await loading(page, tickets, listPath, /^Tickets$/); + catalog.assertClosed(); + await capture(page, "cold-load-gated", tickets); + tickets.release(); + await rows(page); + // Global catalog remains unavailable while the page is fully usable. + catalog.assertClosed(); + const detail = await responseGate(page, "**/api/w/tickets/tickets/T-2"); + try { + await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click(); + await loading(page, detail, detailPath, /^Ticket$/); + catalog.assertClosed(); + detail.release(); + await page.getByRole("heading", { name: "Read-only investigation", exact: true }) + .waitFor(); + catalog.assertClosed(); + await capture(page, "global-catalog-delayed-detail-resolved"); + } finally { + await detail.dispose(); + } + catalog.release(); + } finally { + await catalog.dispose(); + await tickets.dispose(); + } + }); +}); + +Deno.test("immediate navigation: failed read stays at destination and gated Retry recovers", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/orchestrator`); + await page.locator(".orchestrator-assignment").first().waitFor(); + const failure = await responseGate(page, queryPattern, () => true, { + status: 503, + contentType: "application/json", + body: JSON.stringify({ message: "Navigation fixture unavailable" }), + }); + try { + await clickTickets(page); + await loading(page, failure, listPath, /^Tickets$/); + failure.release(); + await page.getByRole("alert").filter({ + hasText: /Ticket Browser API request failed with HTTP 503/, + }) + .waitFor(); + assertEquals(new URL(page.url()).pathname, listPath); + assertEquals( + await page.getByText("No tickets match this query.", { exact: true }).count(), + 0, + ); + await capture(page, "read-error"); + } finally { + await failure.dispose(); + } + const retry = await responseGate(page, queryPattern); + try { + await page.getByRole("button", { name: "Retry", exact: true }).click(); + // Product Retry performs a local query read, not SvelteKit invalidation. + // Its polite live-region status must be visible while that response stays CLOSED. + await retry.held(); + retry.assertClosed(); + assertEquals(new URL(page.url()).pathname, listPath); + await page.getByRole("heading", { name: "Tickets", level: 1, exact: true }).waitFor(); + await page.locator('.ticket-list-page-state[aria-live="polite"]') + .filter({ hasText: /^Loading…$/ }).waitFor(); + retry.assertClosed(); + await capture(page, "read-retry-gated", retry); + retry.release(); + await rows(page); + assertEquals(await page.getByRole("alert").count(), 0); + await capture(page, "read-retry-resolved"); + } finally { + await retry.dispose(); + } + }); +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index b2774d68e..7a01f2a29 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 82c3b97ad..a1d4b1e2f 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index bd0e09663..756b10be9 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -220,11 +220,11 @@ Deno.test("design lab demonstrates nested sidebar slots without flattening level Deno.test("workspace Worker list lives on the dedicated Workers page", async () => { const workspacePage = await Deno.readTextFile( - new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url), + new URL("./../../../routes/w/[workspaceId]/PageContent.svelte", import.meta.url), ); const workersPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/PageContent.svelte", import.meta.url, ), ); @@ -337,7 +337,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls" ); const ticketsPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/tickets/+page.svelte", + "./../../../routes/w/[workspaceId]/tickets/PageContent.svelte", import.meta.url, ), ); @@ -349,7 +349,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls" ); const ticketDetailPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", + "./../../../routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte", import.meta.url, ), ); @@ -364,7 +364,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls" ); const repositoryPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte", + "./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/PageContent.svelte", import.meta.url, ), ); @@ -444,7 +444,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () ); const subjectIndexPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/+page.svelte", + "./../../../routes/w/[workspaceId]/memory/PageContent.svelte", import.meta.url, ), ); @@ -456,7 +456,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () ); const subjectPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte", import.meta.url, ), ); @@ -468,7 +468,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () ); const memoryDetailPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte", import.meta.url, ), ); @@ -544,7 +544,7 @@ Deno.test("root layout keeps Workspace selection explicit", async () => { Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -569,7 +569,7 @@ Deno.test("Worker Console uses protocol observation events without transcript fe Deno.test("Worker Console models availability separately from projected lines", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -619,7 +619,7 @@ Deno.test("Worker Console models availability separately from projected lines", Deno.test("Worker Console owns its narrower centered shell width", async () => { const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -733,7 +733,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps" Deno.test("Model information stays below Composer in one compact monospace row", async () => { const page = await Deno.readTextFile(new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, )); const composer = page.indexOf('
{ ); const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -830,7 +830,7 @@ Deno.test("Console and app shell keep compact layout spacing", async () => { Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves scrolling", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -884,7 +884,7 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => { const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -913,7 +913,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -946,7 +946,7 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async ( Deno.test("Worker Console composer separates the editor from its icon toolbar", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -987,7 +987,7 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar", Deno.test("Worker Console keeps Notify beside Submit without persistent notices", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1022,7 +1022,7 @@ Deno.test("Worker Console keeps Notify beside Submit without persistent notices" Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1059,7 +1059,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority" Deno.test("Worker Console routes image paste through the existing attachment upload", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1116,7 +1116,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async ); const runtimesPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte", + "./../../../routes/w/[workspaceId]/settings/runtimes/PageContent.svelte", import.meta.url, ), ); @@ -1125,7 +1125,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async ); const workdirsPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte", + "./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/PageContent.svelte", import.meta.url, ), ); @@ -1170,7 +1170,7 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy ); const newWorkerPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/new/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/new/PageContent.svelte", import.meta.url, ), ); @@ -1192,7 +1192,7 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy Deno.test("Worker Console route resolves logical Worker authority before Runtime APIs", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1375,8 +1375,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho globalNavSections.includes("workspaces") && globalNavSections.includes("workspace.display_name") && globalNavSections.includes("workspaceHref(workspace.workspace_id)") && - rootLayout.includes("workspaces={data.accessibleWorkspaces}") && - rootLayout.includes("workspaceError={data.workspaceCatalogError}") && + rootLayout.includes("observeDeferred(() => data.deferredCatalog") && + rootLayout.includes("workspaces={catalog.data?.accessibleWorkspaces ?? null}") && + rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") && + rootLayoutLoad.includes("deferredCatalog: settle(") && rootLayoutLoad.includes("listWorkspaces(fetch)") && !globalNavSections.includes("Tickets") && !globalNavSections.includes("Repositories"), @@ -1388,6 +1390,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho workspaceLayout.includes("controller={parentSidebarController}") && workspaceLayout.includes("sidebar={workspaceSidebar}") && workspaceLayoutLoad.includes("params.workspaceId") && + workspaceLayoutLoad.includes("deferredWorkspace: settle(") && + workspaceLayout.includes("observeDeferred(() => data.deferredWorkspace") && + workspaceLayout.includes("loaded.data?.workspace ?? null") && + workspaceLayout.includes("loaded.failure?.message ?? null") && workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"), "Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children", ); @@ -1441,7 +1447,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho Deno.test("Workspace Worker list and Console share the multiplexed connection", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1478,7 +1484,7 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection", Deno.test("Web Console renders the client-projected Worker task store", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1517,7 +1523,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async () Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1561,7 +1567,7 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => { const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1594,7 +1600,7 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header Deno.test("mini task summary owns pane toggling instead of the header", async () => { const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1625,7 +1631,7 @@ Deno.test("mini task summary owns pane toggling instead of the header", async () Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => { const page = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1675,7 +1681,7 @@ Deno.test("Pending inputs are single-line previews between Tasks and Composer", Deno.test("Web Console uses Notify while running and exposes queued input cancellation", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); @@ -1738,7 +1744,7 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel Deno.test("Web Console gates compaction progress before rendering run status", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte", import.meta.url, ), ); diff --git a/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte b/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte index d0ac944f9..9e6e39cba 100644 --- a/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte +++ b/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte @@ -12,14 +12,15 @@ let { workspaceId, workspace = null, workspaceError = null }: Props = $props(); - const workerName = $derived.by(() => { - const data = page.data as Record; - const worker = data.worker as - | { display_name?: string | null; label?: string | null } - | null - | undefined; - return worker?.display_name ?? worker?.label ?? null; - }); + import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts'; + import { settle, type DeferredResult } from '#lib/workspace/navigation/deferred.ts'; + + type WorkerData = { worker?: { display_name?: string | null; label?: string | null } | null }; + const resource = observeDeferred( + () => (page.data as { deferredPage?: Promise> }).deferredPage ?? settle({} as WorkerData), + () => page.url.pathname, + ); + const workerName = $derived(resource.data?.worker?.display_name ?? resource.data?.worker?.label ?? null); const breadcrumbs = $derived( buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }), ); diff --git a/web/workspace/src/lib/workspace/navigation/DeferredPage.svelte b/web/workspace/src/lib/workspace/navigation/DeferredPage.svelte new file mode 100644 index 000000000..e69690534 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/DeferredPage.svelte @@ -0,0 +1,79 @@ + + + + {#if currentFailure}{title} · Unavailable + {:else if !current}{title} · Loading{/if} + + +{#if currentFailure} +
+

{title}

+

{currentFailure.message}

+ +
+{:else if current} + {#if loading}

Updating…

{/if} + {#key path}{@render children(current)}{/key} +{:else} +
+

{title}

+

Loading…

+
+{/if} + + diff --git a/web/workspace/src/lib/workspace/navigation/deferred-page.browser.spec.ts b/web/workspace/src/lib/workspace/navigation/deferred-page.browser.spec.ts new file mode 100644 index 000000000..d514afa01 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/deferred-page.browser.spec.ts @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRawSnippet, tick } from "svelte"; +import DeferredPage from "./DeferredPage.svelte"; +import { type DeferredResult, settle } from "./deferred.ts"; + +const navigation = vi.hoisted(() => ({ + goto: vi.fn(async () => {}), + invalidateAll: vi.fn(async () => {}), + to: null as { url: URL } | null, +})); +vi.mock("$app/navigation", () => navigation); +vi.mock( + "$app/state", + () => ({ page: { url: new URL("https://example.test/w/team/tickets") }, navigating: navigation }), +); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + navigation.to = null; +}); +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve: (value: T) => resolve(value) }; +} +type Content = { label: string }; +const TestPage = DeferredPage< + { deferredPage: Promise> } +>; +const children = createRawSnippet<[Content]>((getData) => ({ + render: () => + `

${getData().label}

`, +})); + +test("pending route shows destination loading without constructing its editor", async () => { + const pending = gate>(); + const view = render(TestPage, { + data: { deferredPage: pending.promise }, + title: "Tickets", + children, + }); + expect(view.getByRole("heading").textContent).toBe("Tickets"); + expect(view.getByRole("status").textContent).toBe("Loading…"); + expect(view.queryByLabelText("Draft")).toBeNull(); + pending.resolve({ ok: true, data: { label: "First page" } }); + await view.findByLabelText("Draft"); + expect(view.queryByRole("status")).toBeNull(); +}); + +test("same-path refresh keeps the mounted input and pending edits", async () => { + const view = render(TestPage, { + data: { deferredPage: settle({ label: "First page" }) }, + title: "Tickets", + children, + }); + const input = await view.findByLabelText("Draft") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "Unsent draft" } }); + const refresh = gate>(); + await view.rerender({ + data: { deferredPage: refresh.promise }, + title: "Tickets", + children, + }); + expect(view.getByRole("status").textContent).toBe("Updating…"); + expect(view.getByLabelText("Draft")).toBe(input); + refresh.resolve({ ok: true, data: { label: "Fresh data" } }); + await waitFor(() => expect(view.queryByRole("status")).toBeNull()); + expect(view.getByLabelText("Draft")).toBe(input); + expect(input.value).toBe("Unsent draft"); +}); + +test("late previous load cannot redirect or overwrite a newer result", async () => { + const old = gate>(); + const view = render(TestPage, { + data: { deferredPage: old.promise }, + title: "Tickets", + children, + }); + await view.rerender({ + data: { deferredPage: settle({ label: "Current page" }) }, + title: "Tickets", + children, + }); + await view.findByText("Current page"); + old.resolve({ + ok: false, + failure: { message: "Redirecting…", status: 308, redirect: "/stale" }, + }); + await tick(); + expect(navigation.goto).not.toHaveBeenCalled(); + expect(view.getByText("Current page")).toBeTruthy(); +}); + +test("permission failure never renders content and Retry requests fresh load", async () => { + const view = render(TestPage, { + data: { + deferredPage: Promise.resolve({ + ok: false as const, + failure: { message: "Permission denied", status: 403, redirect: null }, + }), + }, + title: "Settings", + children, + }); + await view.findByRole("alert"); + expect(view.queryByLabelText("Draft")).toBeNull(); + await fireEvent.click(view.getByRole("button", { name: "Retry" })); + expect(navigation.invalidateAll).toHaveBeenCalledOnce(); +}); + +test("denied refresh clears cached controls throughout a pending Retry", async () => { + const view = render(TestPage, {data:{deferredPage:settle({label:"Authorized"})}, title:"Settings", children}); + await view.findByLabelText("Draft"); + await view.rerender({data:{deferredPage:Promise.resolve({ok:false, failure:{message:"Denied",status:403,redirect:null}})}, title:"Settings", children}); + await view.findByRole("alert"); + const retry=gate>(); + await view.rerender({data:{deferredPage:retry.promise},title:"Settings",children}); + expect(view.queryByLabelText("Draft")).toBeNull(); + expect(view.getByRole("status").textContent).toBe("Loading…"); +}); + +test("query-derived launch identity remounts the editor even on the same pathname", async () => { + const view=render(TestPage,{data:{deferredPage:settle({label:"Ticket A"})},title:"New Worker",identity:"team:ticket-a",children}); + const old=await view.findByLabelText("Draft") as HTMLInputElement; + old.value="Ticket A instruction"; + await view.rerender({data:{deferredPage:settle({label:"Ticket B"})},title:"New Worker",identity:"team:ticket-b",children}); + await view.findByText("Ticket B"); + expect(view.getByLabelText("Draft")).not.toBe(old); + expect((view.getByLabelText("Draft") as HTMLInputElement).value).toBe(""); +}); + +test("old canonical redirect cannot overtake a navigation still loading its route module", async () => { + const old=gate>(); + render(TestPage,{data:{deferredPage:old.promise},title:"Tickets",children}); + navigation.to={url:new URL("https://example.test/w/team/objectives")}; + old.resolve({ok:false,failure:{message:"Redirecting…",status:308,redirect:"/stale"}}); + await tick(); + expect(navigation.goto).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.test.ts b/web/workspace/src/lib/workspace/navigation/deferred.test.ts new file mode 100644 index 000000000..238567212 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/deferred.test.ts @@ -0,0 +1,96 @@ +import { error, redirect } from "@sveltejs/kit"; +import { deferPage, resolvePageData, settle } from "./deferred.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function assert(condition: unknown): asserts condition { + if (!condition) throw new Error("Assertion failed"); +} +function gate() { + let release!: (value: T) => void; + return { + promise: new Promise((resolve) => { + release = resolve; + }), + release: (value: T) => release(value), + }; +} + +Deno.test("deferred load returns before API completion and snapshots navigation dependencies", async () => { + const api = gate(); + const event = { + params: { id: "first" }, + url: new URL("https://example.test/first?q=old"), + }; + const load = deferPage(async ({ params, url }: typeof event) => ({ + value: await api.promise, + id: params.id, + query: url.searchParams.get("q"), + })); + const response = load(event); + assert(!(response instanceof Promise)); + event.params.id = "second"; + event.url.searchParams.set("q", "new"); + api.release("complete"); + const result = await response.deferredPage; + assert( + result.ok && result.data.id === "first" && result.data.query === "old" && + result.data.value === "complete", + ); +}); + +Deno.test("deferred redirects and HTTP failures are handled results, not unhandled rejections", async () => { + for ( + const [run, status, location] of [ + [() => redirect(308, "/canonical"), 308, "/canonical"], + [() => error(403, "Permission denied"), 403, null], + [ + () => { + throw new Error("Failed read"); + }, + null, + null, + ], + ] as const + ) { + const load = deferPage((_event: { params: {}; url: URL }) => run()); + const result = await load({ + params: {}, + url: new URL("https://example.test/"), + }).deferredPage; + assert( + !result.ok && result.failure.status === status && + result.failure.redirect === location, + ); + } +}); + +Deno.test("page content cannot override a failed Workspace permission lookup", async () => { + const result = await resolvePageData({ + deferredWorkspace: settle(Promise.reject(new Error("Workspace denied"))), + deferredPage: settle({ title: "Sensitive content" }), + }); + assert(!result.ok && result.failure.message === "Workspace denied"); +}); + +Deno.test("resolved data preserves page overrides without waiting for the global catalog", async () => { + const catalog = gate(); + const result = await resolvePageData({ + deferredWorkspace: settle({ + workspace: { name: "team" }, + repositories: { items: [] }, + }), + deferredCatalog: catalog.promise, + workspaceId: "team", + deferredPage: settle({ + repositories: { data: null, error: "Unavailable" }, + ticket: "T-1", + }), + }); + assert( + result.ok && result.data.repositories.error === "Unavailable" && + result.data.workspace.name === "team" && result.data.ticket === "T-1" && + result.data.workspaceId === "team", + ); +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.ts b/web/workspace/src/lib/workspace/navigation/deferred.ts new file mode 100644 index 000000000..c3baaf145 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/deferred.ts @@ -0,0 +1,104 @@ +import { isHttpError, isRedirect } from "@sveltejs/kit"; + +export type DeferredFailure = { + message: string; + status: number | null; + redirect: string | null; +}; +export type DeferredResult = + | { ok: true; data: T } + | { ok: false; failure: DeferredFailure }; + +// Routerへは即座に戻り、拒否されたPromiseも遷移後の表示境界で扱う。 +// 読込み中のキャンセルは外部副作用の取消ではなく、古い結果の表示抑止とする。 +export async function settle( + value: T | PromiseLike, +): Promise> { + try { + return { ok: true, data: await value }; + } catch (error) { + return { + ok: false, + failure: isRedirect(error) + ? { + message: "Redirecting…", + status: error.status, + redirect: error.location, + } + : { + message: isHttpError(error) + ? error.body.message + : error instanceof Error + ? error.message + : String(error), + status: isHttpError(error) ? error.status : null, + redirect: null, + }, + }; + } +} + +export function deferPage< + E extends { params: Record; url: URL }, + T extends object, +>( + loader: (event: E) => T | Promise, +): (event: E) => { deferredPage: Promise> } { + return (event) => { + // 遅延処理が読むparams/URLも、loadが返る前にRouterの依存として記録する。 + const snapshot = { + ...event, + params: { ...event.params }, + url: new URL(event.url.href), + }; + try { + return { deferredPage: settle(loader(snapshot)) }; + } catch (error) { + return { deferredPage: settle(Promise.reject(error)) }; + } + }; +} + +type Merge = Omit & B; +type WorkspaceValue = T extends + { deferredWorkspace: Promise> } ? W : {}; +type PageValue = T extends { deferredPage: Promise> } + ? P + : {}; +export type ResolvedPageData = Merge< + Merge< + WorkspaceValue, + Omit + >, + PageValue +>; + +type DeferredData = { + deferredWorkspace?: Promise>; + deferredPage?: Promise>; + deferredCatalog?: unknown; +}; + +export async function resolvePageData( + data: T, +): Promise>> { + const { + deferredWorkspace, + deferredPage, + deferredCatalog: _catalog, + ...base + } = data; + const [workspace, content] = await Promise.all([ + deferredWorkspace ?? settle({}), + deferredPage ?? settle({}), + ]); + // Workspace取得の失敗をpage成功で補わず、権限の確認前に操作を描画しない。 + if (!workspace.ok) return workspace; + if (!content.ok) return content; + return { + ok: true, + data: { ...workspace.data, ...base, ...content.data } as ResolvedPageData< + T + >, + }; +} diff --git a/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts b/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts new file mode 100644 index 000000000..82458c7aa --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts @@ -0,0 +1,43 @@ +import type { DeferredFailure, DeferredResult } from "./deferred.ts"; + +// 同じ対象の再取得では確定済み表示を保持し、対象切替では旧値を即座に隠す。 +export function observeDeferred( + read: () => Promise>, + identity: () => string, +) { + let settled = $state.raw<{ identity: string; value: T } | null>(null); + let failure = $state.raw<{ identity: string; value: DeferredFailure } | null>( + null, + ); + let loading = $state(true); + $effect(() => { + const key = identity(); + const promise = read(); + let active = true; + loading = true; + failure = null; + void promise.then((result) => { + if (!active) return; + loading = false; + if (result.ok) settled = { identity: key, value: result.data }; + else { + settled = null; + failure = { identity: key, value: result.failure }; + } + }); + return () => { + active = false; + }; + }); + return { + get data() { + return settled?.identity === identity() ? settled.value : null; + }, + get failure() { + return failure?.identity === identity() ? failure.value : null; + }, + get loading() { + return loading; + }, + }; +} diff --git a/web/workspace/src/lib/workspace/navigation/refresh.browser.spec.ts b/web/workspace/src/lib/workspace/navigation/refresh.browser.spec.ts new file mode 100644 index 000000000..94512aba2 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/refresh.browser.spec.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { afterEach, expect, test, vi } from "vitest"; +import { refreshPageData } from "./refresh.ts"; +import { type DeferredResult, settle } from "./deferred.ts"; +const state = vi.hoisted(() => ({ url: new URL("https://example.test/settings"), data: {} as object, invalidateAll: vi.fn(async () => {}) })); +vi.mock("$app/navigation", () => ({ invalidateAll: state.invalidateAll })); +vi.mock("$app/state", () => ({page: state})); +afterEach(() => { vi.clearAllMocks(); }); +function gate() { let release!: (value:T)=>void; const promise=new Promise(resolve=>{release=resolve});return{promise,release:(value:T)=>release(value)}; } + +test("mutation reload waits for newly published Workspace and page authority", async () => { + const workspace=gate>(); + const content=gate>(); + state.data={deferredPage:settle({old:true})}; + state.invalidateAll.mockImplementation(async()=>{state.data={deferredWorkspace:workspace.promise,deferredPage:content.promise}}); + let complete=false; + const refresh=refreshPageData().then(()=>{complete=true}); + await Promise.resolve(); + workspace.release({ok:true,data:{}}); + await Promise.resolve(); + expect(complete).toBe(false); + content.release({ok:true,data:{}}); + await refresh; + expect(complete).toBe(true); +}); + +test("denied or superseded reload is not reported as refreshed authority", async () => { + state.invalidateAll.mockImplementation(async()=>{state.data={deferredPage:Promise.resolve({ok:false,failure:{message:"Denied",status:403,redirect:null}})}}); + await expect(refreshPageData()).rejects.toThrow("Denied"); + const pending=gate>(); + state.invalidateAll.mockImplementation(async()=>{state.data={deferredPage:pending.promise}}); + const refresh=refreshPageData(); + await Promise.resolve(); + state.data={deferredPage:settle({newer:true})}; + pending.release({ok:true,data:{}}); + await expect(refresh).rejects.toThrow("superseded"); +}); diff --git a/web/workspace/src/lib/workspace/navigation/refresh.ts b/web/workspace/src/lib/workspace/navigation/refresh.ts new file mode 100644 index 000000000..3452889c2 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/refresh.ts @@ -0,0 +1,20 @@ +import { invalidateAll } from "$app/navigation"; +import { page } from "$app/state"; +import { type DeferredResult, resolvePageData } from "./deferred.ts"; + +// mutation後のauthority再取得は、RouterがPromiseを差し替えた時点では完了していない。 +// 保持中のcontent propsではなく、新しく公開されたroute dataの応答まで待つ。 +export async function refreshPageData(): Promise { + const url = page.url.href; + await invalidateAll(); + if (page.url.href !== url) throw new Error("Page changed while refreshing"); + const data = page.data as { + deferredWorkspace?: Promise>; + deferredPage?: Promise>; + }; + const result = await resolvePageData(data); + if (page.url.href !== url || page.data !== data) { + throw new Error("Page refresh was superseded"); + } + if (!result.ok) throw new Error(result.failure.message); +} diff --git a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte index fcf7fc024..74c2e1943 100644 --- a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte @@ -7,7 +7,7 @@ type Props = { currentPath: string; content?: SidebarSnippet | null; - workspaces?: WorkspaceCatalogRecord[]; + workspaces?: WorkspaceCatalogRecord[] | null; workspaceError?: string | null; }; diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index a0b1966d3..7a0f9cff7 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -79,7 +79,7 @@ {#if content} {@render content()} - {:else} + {:else if workspace && !ownsRoutePath(workspaceSettingsHref, currentPath)}