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) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-01 16:04:46 +02:00
co-authored by Claude Opus 4.8
parent 3345837574
commit 07231fec7d
2 changed files with 53 additions and 4 deletions
+20
View File
@@ -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))
}
@@ -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=<id>` 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))