From 07231fec7d5e0d215877a156e6ebf9b0e69f9ded Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 1 Jun 2026 16:04:46 +0200 Subject: [PATCH] feat(frontend): sync active workspace into ?workspace= URL param (stage 1) Make the logged layout the single place that keeps the URL's ?workspace= param in sync with the active workspace store, in both directions: URL -> store on navigation (existing, now gated) and store -> URL via replaceState (new). Excludes auth/selection/oauth routes via a new workspaceParamAllowed predicate. replaceState avoids history pollution; guards keep the sync idempotent and loop-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/workspaceParam.ts | 20 ++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 37 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/workspaceParam.ts diff --git a/frontend/src/lib/workspaceParam.ts b/frontend/src/lib/workspaceParam.ts new file mode 100644 index 0000000000..b17f7f43e4 --- /dev/null +++ b/frontend/src/lib/workspaceParam.ts @@ -0,0 +1,20 @@ +// Routes that must never carry the `?workspace=` query param. +// +// `/user/*` covers auth, workspace selection, onboarding, invites and +// instance-level settings — none of which are scoped to an active workspace +// (and several run before one is chosen). `/oauth/*` covers OAuth callbacks +// and `mcp_authorize`, which carries its own `workspace_id`. +// +// Public, path-scoped routes (`/a`, `/public`, `/approve`, +// `/apps_raw/[workspace]`) live outside the logged layout, so this predicate is +// never consulted for them — the workspace already lives in their path. +const WORKSPACE_PARAM_EXCLUDED_PREFIXES = ['/user/', '/oauth/'] + +/** + * Whether the `?workspace=` query param should be kept in sync with the active + * workspace store on the given route. Drives both directions of the sync in the + * logged layout: URL → store on navigation, and store → URL via replaceState. + */ +export function workspaceParamAllowed(pathname: string): boolean { + return !WORKSPACE_PARAM_EXCLUDED_PREFIXES.some((prefix) => pathname.startsWith(prefix)) +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index c6fca60e37..85e6c5c800 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -36,8 +36,9 @@ globalForkModal } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' - import { afterNavigate, beforeNavigate } from '$app/navigation' + import { afterNavigate, beforeNavigate, replaceState } from '$app/navigation' import { goto } from '$lib/navigation' + import { workspaceParamAllowed } from '$lib/workspaceParam' import UserSettings from '$lib/components/UserSettings.svelte' import SuperadminSettings from '$lib/components/SuperadminSettings.svelte' import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' @@ -113,9 +114,11 @@ } function onQueryChange() { - let queryWorkspace = page.url.searchParams.get('workspace') - if (queryWorkspace) { - $workspaceStore = queryWorkspace + if (workspaceParamAllowed(page.url.pathname)) { + const queryWorkspace = page.url.searchParams.get('workspace') + if (queryWorkspace && queryWorkspace !== $workspaceStore) { + $workspaceStore = queryWorkspace + } } menuHidden = @@ -123,6 +126,25 @@ page.url.pathname.startsWith('/oauth/callback/') } + // Reflect the active workspace into the URL as `?workspace=` so links are + // workspace-explicit and shareable, and so separate tabs stay independent. + // Uses replaceState to avoid polluting browser history on every navigation. + // Guarded to be idempotent: it only writes when the param actually differs, + // which also prevents a ping-pong with the URL → store sync in onQueryChange. + function syncWorkspaceToUrl() { + if (!BROWSER) return + const ws = $workspaceStore + if (!ws || !workspaceParamAllowed(page.url.pathname)) return + if (page.url.searchParams.get('workspace') === ws) return + try { + const url = new URL(page.url) + url.searchParams.set('workspace', ws) + replaceState(url, page.state) + } catch (e) { + console.warn('Could not sync workspace to URL', e) + } + } + async function updateUserStore(workspace: string | undefined) { if (workspace) { try { @@ -413,6 +435,13 @@ $effect(() => { page.url && untrack(() => onQueryChange()) }) + $effect(() => { + // Re-run on both store changes (workspace switch) and navigation (route + // entered without the param). Both reads register as dependencies. + $workspaceStore + page.url + untrack(() => syncWorkspaceToUrl()) + }) $effect(() => { $workspaceStore untrack(() => updateUserStore($workspaceStore))