fix: resolve fork family/picker for superadmin visiting a non-member workspace (#10023)

* fix: populate fork base picker for superadmin visiting a non-member workspace

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: resolve fork family for superadmin across sidebar picker and scope header

Extract the superadmin-visited-workspace fallback into a shared useForkableWorkspaces composable and apply it to WorkspaceFamilyPicker and WorkspaceScopeHeader so the sidebar fork picker and its fork-count trigger resolve the family for a superadmin viewing a non-member workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: resolve superadmin-visited workspace name in the scope trigger chip

The sidebar scope trigger next to the fork picker read $userWorkspaces directly, so a superadmin viewing a non-member workspace saw its raw id instead of the resolved name/family. Thread the folded-in forkable list into WorkspaceScopeTrigger, and trim the now-duplicated per-site rationale comments to a pointer at the composable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-09 14:40:46 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c537d45e49
commit 368fd2d9e4
5 changed files with 113 additions and 19 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { userWorkspaces } from '$lib/stores'
import { userWorkspaces, type UserWorkspace } from '$lib/stores'
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { Badge, Button } from '$lib/components/common'
@@ -24,6 +24,7 @@
interactive = true,
menuItems = undefined,
disableTitle = false,
forkableWorkspaces,
class: className = ''
}: {
workspaceId?: string
@@ -55,19 +56,23 @@
// Suppress the chip's native `title` so it doesn't double-pop when a
// consumer wraps the chip in a richer hover tooltip (e.g. NameIdTooltip).
disableTitle?: boolean
// Pre-resolved workspace list from a consumer that folded in a
// superadmin-visited workspace (see useForkableWorkspaces), so the chip
// resolves its name/family for a workspace absent from `$userWorkspaces`.
// Omitted consumers fall back to the member-only list.
forkableWorkspaces?: UserWorkspace[]
class?: string
} = $props()
const root = $derived(findWorkspaceRoot(workspaceId, $userWorkspaces))
const currentWs = $derived(
workspaceId ? $userWorkspaces.find((w) => w.id === workspaceId) : undefined
)
const workspaces = $derived(forkableWorkspaces ?? $userWorkspaces)
const root = $derived(findWorkspaceRoot(workspaceId, workspaces))
const currentWs = $derived(workspaceId ? workspaces.find((w) => w.id === workspaceId) : undefined)
const isFork = $derived(!!currentWs && !!root && currentWs.id !== root.id)
const showFork = $derived(!!pendingFork || isFork)
const name = $derived(pendingFork?.name ?? currentWs?.name ?? workspaceId ?? 'Pick workspace')
const parentId = $derived(pendingFork?.parent_workspace_id ?? currentWs?.parent_workspace_id)
const parentName = $derived(
parentId ? ($userWorkspaces.find((w) => w.id === parentId)?.name ?? parentId) : undefined
parentId ? (workspaces.find((w) => w.id === parentId)?.name ?? parentId) : undefined
)
// The parent segment only appears for a fork of a fork — forking off the
// root is the default and doesn't need spelling out.
@@ -3,9 +3,11 @@
import {
enterpriseLicense,
isPremiumStore,
superadmin,
userStore,
userWorkspaces,
workspaceStore
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import {
findWorkspaceDescendants,
@@ -13,6 +15,7 @@
findWorkspaceRoot,
buildWorkspaceHierarchy
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { canCreateFork } from '$lib/utils/editInFork'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { getUserExt } from '$lib/user'
@@ -65,6 +68,10 @@
// and target href.
settingsHref,
settingsLabel,
// Pre-resolved forkable list from a parent that already computed it (e.g.
// WorkspaceScopeHeader), so the superadmin lookup isn't duplicated. Omitted
// by standalone consumers, which then resolve it themselves.
forkableWorkspaces: forkableWorkspacesProp,
trigger
}: {
selectedId?: string
@@ -77,18 +84,29 @@
class?: string
settingsHref?: string
settingsLabel?: string
forkableWorkspaces?: UserWorkspace[]
trigger: Snippet<[{ open: boolean }]>
} = $props()
const WM_FORK_PREFIX = 'wm-fork-'
const effectiveId = $derived(selectedId ?? $workspaceStore ?? undefined)
const root = $derived(findWorkspaceRoot(effectiveId, $userWorkspaces))
const forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
// Resolve the family (see useForkableWorkspaces); skip the lookup when a parent already supplied it.
const ownForkable = useForkableWorkspaces({
workspaces: () => $userWorkspaces,
currentWorkspaceId: () => effectiveId,
isSuperadmin: () => !!$superadmin,
enabled: () => forkableWorkspacesProp === undefined
})
const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current)
const root = $derived(findWorkspaceRoot(effectiveId, forkableWorkspaces))
const forks = $derived(root ? findWorkspaceDescendants(root.id, forkableWorkspaces) : [])
// The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be
// forked via its dev) and as a selectable base with a "dev" badge.
const devOfRoot = $derived(root ? findCanonicalDevWorkspace(root.id, $userWorkspaces) : undefined)
const devOfRoot = $derived(
root ? findCanonicalDevWorkspace(root.id, forkableWorkspaces) : undefined
)
const createForkLabel = 'Create new fork…'
// Candidate bases ("targets") for a new fork: the root plus every fork/dev in the family, so a fork
// can itself be the base — i.e. a fork of a fork. Root first, matching the list order below.
@@ -108,7 +126,7 @@
// forks of forks under their parent the same way. `forks` is a DFS of descendants (parent before
// child), so indenting each row by its depth nests it under its parent.
const familyDepths = $derived(
new Map(buildWorkspaceHierarchy($userWorkspaces).map((h) => [h.workspace.id, h.depth]))
new Map(buildWorkspaceHierarchy(forkableWorkspaces).map((h) => [h.workspace.id, h.depth]))
)
// Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level,
// matching the sidebar menu's `depth * 16px`.
@@ -13,19 +13,28 @@
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import { findWorkspaceRoot, findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
const effectiveId = $derived($workspaceStore ?? undefined)
// Resolve once here and pass to the picker + trigger below, avoiding a duplicate lookup (see
// useForkableWorkspaces).
const forkable = useForkableWorkspaces({
workspaces: () => $userWorkspaces,
currentWorkspaceId: () => effectiveId,
isSuperadmin: () => !!$superadmin
})
const forkableWorkspaces = $derived(forkable.current)
const currentWs = $derived(
effectiveId ? $userWorkspaces.find((w) => w.id === effectiveId) : undefined
effectiveId ? forkableWorkspaces.find((w) => w.id === effectiveId) : undefined
)
// Fork count surfaces the family's size right on the trigger, hinting that
// the muted "root" chip is also the entry point to its forks.
const forkCount = $derived.by(() => {
const root = findWorkspaceRoot(effectiveId, $userWorkspaces)
return root ? findWorkspaceDescendants(root.id, $userWorkspaces).length : 0
const root = findWorkspaceRoot(effectiveId, forkableWorkspaces)
return root ? findWorkspaceDescendants(root.id, forkableWorkspaces).length : 0
})
const rootLabel = $derived(`${forkCount} fork${forkCount === 1 ? '' : 's'}`)
@@ -50,6 +59,7 @@
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
<WorkspaceFamilyPicker
selectedId={effectiveId}
{forkableWorkspaces}
onPick={switchWorkspaceDirect}
onRequestCreateFork={openForkModal}
{settingsHref}
@@ -59,6 +69,7 @@
{#snippet trigger()}
<WorkspaceScopeTrigger
workspaceId={effectiveId}
{forkableWorkspaces}
{isCollapsed}
{rootLabel}
wrap
@@ -14,12 +14,13 @@
import { validateUsername } from '$lib/utils'
import { logoutWithRedirect } from '$lib/logoutKit'
import { page } from '$app/state'
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { superadmin, usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
workspaceIsFork,
findWorkspaceRoot,
findWorkspaceDescendants
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { resource } from 'runed'
import { Badge, Button } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
@@ -83,10 +84,19 @@
// a fork here (rather than the root) yields a fork of a fork.
let baseWorkspaceId = $state<string | undefined>(undefined)
// Base list for forking, folding in a superadmin-visited workspace (see useForkableWorkspaces).
const forkable = useForkableWorkspaces({
workspaces: () => $userWorkspaces,
currentWorkspaceId: () => $workspaceStore,
isSuperadmin: () => !!$superadmin,
enabled: () => isFork
})
let forkableWorkspaces = $derived(forkable.current)
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, $userWorkspaces))
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
let baseCandidates = $derived(
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, $userWorkspaces)] : []
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : []
)
let baseItems = $derived(
baseCandidates.map((w) => ({
@@ -112,13 +122,13 @@
// The dev-workspace option is only offered when forking a root workspace that doesn't already
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
let baseWorkspaceEntry = $derived($userWorkspaces.find((w) => w.id === baseWorkspaceId))
let baseWorkspaceEntry = $derived(forkableWorkspaces.find((w) => w.id === baseWorkspaceId))
// Require the base workspace to be loaded before treating it as a root: a missing entry must
// not read as root (it would offer invalid dev creation while the workspace list is still loading).
// `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent
// FK was set null — it has no parent but is still a fork, so it can't host a dev workspace.
let currentIsRoot = $derived(
!!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, $userWorkspaces)
!!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, forkableWorkspaces)
)
// Ask the server whether a dev already exists: the caller may not be a member of this prod's dev,
// so the client workspace list can't see it and would offer an invalid "create dev" action.
@@ -0,0 +1,50 @@
import { resource } from 'runed'
import { WorkspaceService } from '$lib/gen'
import type { UserWorkspace } from '$lib/stores'
/**
* `$userWorkspaces` only lists workspaces the user is a *member* of. A superadmin can navigate into a
* workspace they don't belong to, which leaves fork surfaces (the base picker, the family picker)
* unable to resolve that workspace's family — so it appears empty. This composable fetches the
* visited-but-unlisted workspace's metadata as superadmin and appends it, so the family root resolves
* and can be forked. Its wider family (ancestors/siblings the caller can't see) stays unreachable
* client-side, so only the visited workspace itself is added.
*/
export function useForkableWorkspaces(args: {
workspaces: () => UserWorkspace[]
currentWorkspaceId: () => string | undefined
isSuperadmin: () => boolean
// Skip the lookup entirely when the fork surface isn't in play (defaults to always enabled).
enabled?: () => boolean
}) {
const missing = resource(
() => {
const id = args.currentWorkspaceId()
const enabled = args.enabled?.() ?? true
return enabled && args.isSuperadmin() && id && !args.workspaces().some((w) => w.id === id)
? id
: undefined
},
async (ws) =>
ws ? await WorkspaceService.getWorkspaceAsSuperAdmin({ workspace: ws }) : undefined
)
return {
get current(): UserWorkspace[] {
const base = args.workspaces()
const w = missing.current
// Drop the fetched entry once membership catches up, so the id is never duplicated.
if (!w || base.some((b) => b.id === w.id)) return base
return [
...base,
{
id: w.id,
name: w.name,
username: '',
color: w.color ?? undefined,
parent_workspace_id: w.parent_workspace_id,
disabled: false
}
]
}
}
}