From 0caf054295fccaf3d8993e562f7bca3049d9e4d9 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 23 Sep 2026 18:23:59 +0200 Subject: [PATCH] fix: check pinned versions and app kind before keeping an item page on switch Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/components/common/table/AppRow.svelte | 3 +- .../components/common/table/FlowRow.svelte | 3 +- .../components/common/table/ScriptRow.svelte | 3 +- .../components/sidebar/workspaceSwitchUrl.ts | 86 +++++++++++++------ .../src/routes/(root)/(logged)/+layout.svelte | 5 +- 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index bd881a31b7..a3e404f1a2 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -247,8 +247,7 @@ { displayName: editInForkLabel($workspaceStore, $userWorkspaces), icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. + // No `href`: the handler resolves the destination asynchronously. action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path), hide: $userStore?.operator || diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index f9af803694..27dadc2194 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -272,8 +272,7 @@ { displayName: editInForkLabel($workspaceStore, $userWorkspaces), icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. + // No `href`: the handler resolves the destination asynchronously. action: (e) => onEditInForkClick(e, 'flow', path), hide: $userStore?.operator || diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 938086a0e8..4c132535f8 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -358,8 +358,7 @@ { displayName: editInForkLabel($workspaceStore, $userWorkspaces), icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. + // No `href`: the handler resolves the destination asynchronously. action: (e) => onEditInForkClick(e, 'script', script.path), hide: $userStore?.operator || diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index 34e625759a..d790c10384 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -14,35 +14,61 @@ import { workspaceStore } from '$lib/stores' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { workspaceAIClients } from '$lib/components/copilot/lib' -type ItemExists = (workspace: string, param: string) => Promise +// `search` is the page's query string: a `hash` or `version` in it pins one version of the +// item, which must exist in the target workspace, not just its path. +type ItemExists = (workspace: string, param: string, search: URLSearchParams) => Promise -const appExists: ItemExists = (workspace, path) => AppService.existsApp({ workspace, path }) -const flowExists: ItemExists = (workspace, path) => - FlowService.existsFlowByPath({ workspace, path }) -const scriptExists: ItemExists = (workspace, path) => - ScriptService.existsScriptByPath({ workspace, path }) +// A failed lookup (404 included) counts as missing. +async function succeeds(lookup: () => Promise): Promise { + try { + await lookup() + return true + } catch { + return false + } +} + +const scriptHashExists = (workspace: string, hash: string) => + succeeds(() => ScriptService.getScriptByHash({ workspace, hash })) + +// The two app editors can't open each other's kind, so the target's app must match it. +const appExists = + (raw: boolean): ItemExists => + async (workspace, path) => { + try { + return (await AppService.getAppLiteByPath({ workspace, path })).raw_app === raw + } catch { + return false + } + } + +const flowExists: ItemExists = async (workspace, path, search) => { + const version = Number(search.get('version') ?? NaN) + if (!Number.isFinite(version)) return FlowService.existsFlowByPath({ workspace, path }) + try { + return (await FlowService.getFlowVersion({ workspace, version })).path === path + } catch { + return false + } +} + +const scriptExists: ItemExists = (workspace, path, search) => { + const hash = search.get('hash') + return hash + ? scriptHashExists(workspace, hash) + : ScriptService.existsScriptByPath({ workspace, path }) +} // The script page's param is a hash, a path, or a hub path. const scriptHashOrPathExists: ItemExists = async (workspace, hashOrPath) => { if (hashOrPath.startsWith('hub/')) return true - if (await scriptExists(workspace, hashOrPath)) return true - try { - await ScriptService.getScriptByHash({ workspace, hash: hashOrPath }) - return true - } catch { - return false - } + if (await ScriptService.existsScriptByPath({ workspace, path: hashOrPath })) return true + return scriptHashExists(workspace, hashOrPath) } // A one-row lookup, where getJob would download the job's args and result. -const jobExists: ItemExists = async (workspace, id) => { - try { - await JobService.getRootJobId({ workspace, id }) - return true - } catch { - return false - } -} +const jobExists: ItemExists = (workspace, id) => + succeeds(() => JobService.getRootJobId({ workspace, id })) type ItemPage = { param: string @@ -62,14 +88,18 @@ const ITEM_PAGES: Record = { '/(root)/(logged)/scripts/get/[...hash]': { param: 'hash', exists: scriptHashOrPathExists }, '/(root)/(logged)/flows/edit/[...path]': { param: 'path', exists: flowExists, draftKind: 'flow' }, '/(root)/(logged)/flows/get/[...path]': { param: 'path', exists: flowExists }, - '/(root)/(logged)/apps/edit/[...path]': { param: 'path', exists: appExists, draftKind: 'app' }, - '/(root)/(logged)/apps/get/[...path]': { param: 'path', exists: appExists }, + '/(root)/(logged)/apps/edit/[...path]': { + param: 'path', + exists: appExists(false), + draftKind: 'app' + }, + '/(root)/(logged)/apps/get/[...path]': { param: 'path', exists: appExists(false) }, '/(root)/(logged)/apps_raw/edit/[...path]': { param: 'path', - exists: appExists, + exists: appExists(true), draftKind: 'raw_app' }, - '/(root)/(logged)/apps_raw/get/[...path]': { param: 'path', exists: appExists }, + '/(root)/(logged)/apps_raw/get/[...path]': { param: 'path', exists: appExists(true) }, '/(root)/(logged)/run/[...run]': { param: 'run', exists: jobExists } } @@ -83,7 +113,7 @@ async function itemPageMissingIn(workspace: string): Promise { const current = currentItemPage() if (!current) return false try { - return !(await current.itemPage.exists(workspace, current.param)) + return !(await current.itemPage.exists(workspace, current.param, page.url.searchParams)) } catch { return true } @@ -121,8 +151,10 @@ export async function switchWorkspaceAndPage( href?: string } ): Promise { + const missing = !opts?.landOnHome && (await itemPageMissingIn(id)) + // Read after the lookup: the user can edit while it is in flight. const unsavedEdits = editorHasUnsavedEdits() - if (opts?.landOnHome || unsavedEdits || (await itemPageMissingIn(id))) { + if (opts?.landOnHome || unsavedEdits || missing) { // Leave before switching: an item page still mounted when the store changes // refetches its item in `id` and toasts the 404. // The param carries the switch through the editor's unsaved-changes prompt: that diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index bfae9bb5d2..4bf7e248fa 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -13,6 +13,7 @@ } from '$lib/gen' import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + import { switchWorkspace } from '$lib/storeUtils' import { isSessionPreviewFrame } from '$lib/components/sessions/sessionMode.svelte' import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' @@ -299,8 +300,8 @@ function onQueryChange() { let queryWorkspace = page.url.searchParams.get('workspace') - if (queryWorkspace) { - $workspaceStore = queryWorkspace + if (queryWorkspace && queryWorkspace !== $workspaceStore) { + switchWorkspace(queryWorkspace) } // When this window is an iframe (e.g. the sessions preview), keep the menu