From 147dd09330963339c9653a82f997c5f864fcb2bf Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 23 Sep 2026 18:01:24 +0200 Subject: [PATCH] fix: route a workspace switch through the editor's unsaved-changes prompt Co-Authored-By: Claude Opus 5.5 (1M context) --- .../sidebar/WorkspacePickerBody.svelte | 2 - .../sidebar/WorkspaceScopeHeader.svelte | 2 - .../components/sidebar/workspaceSwitchUrl.ts | 81 ++++++++++++++++--- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index 8b898ca7c1..40731f35dd 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -28,7 +28,6 @@ import { page } from '$app/state' import MultiplayerMenu from './MultiplayerMenu.svelte' import { isCloudHosted } from '$lib/cloud' - import { workspaceAIClients } from '../copilot/lib' import { twMerge } from 'tailwind-merge' import { ambiguousWorkspaceNames, @@ -63,7 +62,6 @@ // Read before switching: the switch swaps the stores these read out from under us. const landOnHome = landsOnHome(id) const href = workspaceHref(id) - workspaceAIClients.init(id) // On the sessions page, the page's family reconcile swaps out a chat that // doesn't belong to the new workspace's family. await switchWorkspaceAndPage(id, { landOnHome, href }) diff --git a/frontend/src/lib/components/sidebar/WorkspaceScopeHeader.svelte b/frontend/src/lib/components/sidebar/WorkspaceScopeHeader.svelte index 74f2003cef..8783641d29 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceScopeHeader.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceScopeHeader.svelte @@ -8,7 +8,6 @@ } from '$lib/stores' import { switchWorkspaceAndPage } from './workspaceSwitchUrl' import { base } from '$lib/base' - import { workspaceAIClients } from '$lib/components/copilot/lib' import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte' import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte' import { @@ -51,7 +50,6 @@ function switchWorkspaceDirect(id: string) { if ($workspaceStore === id) return - workspaceAIClients.init(id) void switchWorkspaceAndPage(id) } diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index aee2afc520..34e625759a 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -1,8 +1,18 @@ import { page } from '$app/state' import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' -import { AppService, FlowService, JobService, ScriptService } from '$lib/gen' +import { get } from 'svelte/store' +import { + AppService, + FlowService, + JobService, + ScriptService, + type UserDraftItemKind +} from '$lib/gen' import { switchWorkspace } from '$lib/storeUtils' +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 @@ -34,31 +44,70 @@ const jobExists: ItemExists = async (workspace, id) => { } } +type ItemPage = { + param: string + exists: ItemExists + // Set on editors, whose auto-save-off edits are parked under this draft kind. + draftKind?: UserDraftItemKind +} + // Pages showing one workspace item, keyed by route id, with the check that the item named // by the route param exists in a given workspace. -const ITEM_PAGES: Record = { - '/(root)/(logged)/scripts/edit/[...path]': { param: 'path', exists: scriptExists }, +const ITEM_PAGES: Record = { + '/(root)/(logged)/scripts/edit/[...path]': { + param: 'path', + exists: scriptExists, + draftKind: 'script' + }, '/(root)/(logged)/scripts/get/[...hash]': { param: 'hash', exists: scriptHashOrPathExists }, - '/(root)/(logged)/flows/edit/[...path]': { param: 'path', exists: flowExists }, + '/(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 }, + '/(root)/(logged)/apps/edit/[...path]': { param: 'path', exists: appExists, draftKind: 'app' }, '/(root)/(logged)/apps/get/[...path]': { param: 'path', exists: appExists }, - '/(root)/(logged)/apps_raw/edit/[...path]': { param: 'path', exists: appExists }, + '/(root)/(logged)/apps_raw/edit/[...path]': { + param: 'path', + exists: appExists, + draftKind: 'raw_app' + }, '/(root)/(logged)/apps_raw/get/[...path]': { param: 'path', exists: appExists }, '/(root)/(logged)/run/[...run]': { param: 'run', exists: jobExists } } -async function itemPageMissingIn(workspace: string): Promise { +function currentItemPage(): { itemPage: ItemPage; param: string } | undefined { const itemPage = page.route.id ? ITEM_PAGES[page.route.id] : undefined const param = itemPage ? page.params[itemPage.param] : undefined - if (!itemPage || !param) return false + return itemPage && param ? { itemPage, param } : undefined +} + +async function itemPageMissingIn(workspace: string): Promise { + const current = currentItemPage() + if (!current) return false try { - return !(await itemPage.exists(workspace, param)) + return !(await current.itemPage.exists(workspace, current.param)) } catch { return true } } +// Staying on an editor would load the target workspace's copy without a pathname change, so +// its unsaved-changes guard (which only fires on one) would never ask about parked edits. +function editorHasUnsavedEdits(): boolean { + const current = currentItemPage() + const workspace = get(workspaceStore) + if (!current?.itemPage.draftKind || !workspace) return false + const byHash = current.itemPage.draftKind === 'script' && page.url.searchParams.get('hash') + return UserDraftDbSyncer.hasUnsavedDisabledChanges({ + workspace, + itemKind: current.itemPage.draftKind, + path: byHash ? '' : current.param + }) +} + +function switchTo(id: string) { + workspaceAIClients.init(id) + switchWorkspace(id) +} + // Workspace switch shared by the sidebar workspace pickers. An item page stays open when // its item also exists in `id`, and goes home otherwise. export async function switchWorkspaceAndPage( @@ -72,14 +121,20 @@ export async function switchWorkspaceAndPage( href?: string } ): Promise { - if (opts?.landOnHome || (await itemPageMissingIn(id))) { + const unsavedEdits = editorHasUnsavedEdits() + if (opts?.landOnHome || unsavedEdits || (await itemPageMissingIn(id))) { // Leave before switching: an item page still mounted when the store changes // refetches its item in `id` and toasts the 404. - await goto('/') - switchWorkspace(id) + // The param carries the switch through the editor's unsaved-changes prompt: that + // cancels this navigation and, on discard, replays this URL, which the logged layout + // applies. On cancel nothing is switched. + const from = page.route.id + await goto(`/?workspace=${encodeURIComponent(id)}`) + if (unsavedEdits && page.route.id === from) return + switchTo(id) return } - switchWorkspace(id) + switchTo(id) if (opts?.href) { await goto(opts.href) } else if (page.url.searchParams.get('workspace')) {