feat: list the user's own forks first in fork pickers (#11762)

* feat: list the user's own forks first in fork pickers

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

* fix: indent nested fork dividers and order the session rail tree the same way

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-14 12:14:31 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1fab283cf9
commit 009482a677
5 changed files with 127 additions and 34 deletions
@@ -60,7 +60,7 @@
import MenuButton, { sidebarClasses } from '$lib/components/sidebar/MenuButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
@@ -220,7 +220,7 @@
// Workspace tree scoped to the current family (root + its forks), so the rail
// only shows the workspaces in the family we're in — not every workspace.
const workspaceTreeItems = $derived.by(() => {
const all = buildWorkspaceHierarchy($userWorkspaces)
const all = buildWorkspaceHierarchy($userWorkspaces, $userStore?.email)
const currentRoot = $currentWorkspaceRootId
if (!currentRoot) return all
return all.filter((i) => workspaceRootId(i.workspace.id, $userWorkspaces) === currentRoot)
@@ -10,11 +10,11 @@
type UserWorkspace
} from '$lib/stores'
import {
findWorkspaceDescendants,
findCanonicalDevWorkspace,
findDefaultForkBase,
findWorkspaceRoot,
buildWorkspaceHierarchy
buildWorkspaceHierarchy,
workspaceSubtreeRows
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { canCreateFork } from '$lib/utils/editInFork'
@@ -102,7 +102,11 @@
})
const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current)
const root = $derived(findWorkspaceRoot(effectiveId, forkableWorkspaces))
const forks = $derived(root ? findWorkspaceDescendants(root.id, forkableWorkspaces) : [])
// The same hierarchy the sidebar workspace menu builds: forks of forks follow their parent, and the
// user's own forks lead each group of siblings.
const hierarchy = $derived(buildWorkspaceHierarchy(forkableWorkspaces, $userStore?.email))
const forkRows = $derived(root ? workspaceSubtreeRows(root.id, hierarchy) : [])
const forks = $derived(forkRows.map((h) => h.workspace))
// 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.
@@ -126,13 +130,10 @@
}))
)
// Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …), from
// the shared hierarchy builder — the same one the sidebar workspace menu uses — so the list indents
// 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(forkableWorkspaces).map((h) => [h.workspace.id, h.depth]))
)
// Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …).
// `forks` is a DFS (parent before child), so indenting each row by its depth nests it under its
// parent, the same way the sidebar menu does.
const familyDepths = $derived(new Map(hierarchy.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`.
function indentStyle(id: string): string | undefined {
@@ -429,9 +430,16 @@
/>
</div>
{/if}
{#each forks as f, fi (f.id)}
{#each forkRows as { workspace: f, separatorBefore }, fi (f.id)}
{@const forkIdx = (root ? 1 : 0) + fi}
{@const accentStyle = forkAccentStyle(f.color)}
{#if separatorBefore}
<div
class="my-1 border-t border-border-light shrink-0"
style:margin-left={`${((familyDepths.get(f.id) ?? 1) - 1) * 16}px`}
role="separator"
></div>
{/if}
<div class="relative group shrink-0">
<button
type="button"
@@ -136,7 +136,9 @@
// (standalone pages such as svix webhook creation), so
// there forks must stay directly selectable — list the full hierarchy
// unconditionally.
const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : [])
const hierarchy = $derived(
$userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces, $userStore?.email) : []
)
const expandedFamilies = new SvelteSet<string>()
// Root ids with at least one fork — only they get the expand chevron.
// hierarchy is a DFS (parent before child), so a depth>0 row belongs to the
@@ -243,9 +245,16 @@
<!-- The list scrolls internally so the sections below (+ Workspace,
Workspace fork, All workspaces) stay visible however long it gets. -->
<div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]">
{#each familyWorkspaces as { workspace, depth, isForked, parentName }}
{#each familyWorkspaces as { workspace, depth, isForked, parentName, separatorBefore } (workspace.id)}
{@const isActive = $workspaceStore === workspace.id}
{@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined}
{#if separatorBefore}
<div
class="my-1 border-t border-border-light"
style:margin-left={`${(depth - 1) * 16}px`}
role="separator"
></div>
{/if}
<!-- Selection is a trailing tick (single-choice picker convention). A
collapsed root carries the tick while one of its forks is active —
the fork row is hidden and its family is the only trace of the
@@ -381,11 +390,7 @@
{/if}
{#if canManageWorkspace && !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
href="/workspace_settings?workspace={$workspaceStore}"
class={itemClass}
{item}
>
<MenuItem href="/workspace_settings?workspace={$workspaceStore}" class={itemClass} {item}>
<Settings size={16} />
{(activeWorkspace && ambiguousNames.has(activeWorkspace.name)
? activeWorkspace.id
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkspaceHierarchy,
devWorkspacesInChainAbove,
findDefaultForkBase,
findWorkspaceAncestors,
findWorkspaceRoot
findWorkspaceRoot,
workspaceSubtreeRows
} from './workspaceHierarchy'
import type { UserWorkspace } from '../stores'
@@ -75,3 +77,39 @@ describe('devWorkspacesInChainAbove', () => {
expect(devWorkspacesInChainAbove('prod', nestedFamily)).toEqual([])
})
})
describe('buildWorkspaceHierarchy', () => {
const mine = { created_by: 'me@x.com' }
const theirs = { created_by: 'other@x.com' }
const forks = [
ws('wm-fork-a', 'prod', theirs),
ws('wm-fork-b', 'prod', theirs),
ws('wm-fork-c', 'prod', theirs),
ws('wm-fork-y', 'prod', mine),
ws('wm-fork-z', 'prod', mine)
]
const ids = (rows: ReturnType<typeof buildWorkspaceHierarchy>) =>
rows.map((h) => (h.separatorBefore ? '| ' : '') + h.workspace.id)
it("puts the user's own forks after the dev workspace, divided from the rest", () => {
const all = [root, ws('devws', 'prod', { is_dev_workspace: true, ...mine }), ...forks]
expect(ids(workspaceSubtreeRows('prod', buildWorkspaceHierarchy(all, 'me@x.com')))).toEqual([
'devws',
'wm-fork-y',
'wm-fork-z',
'| wm-fork-a',
'wm-fork-b',
'wm-fork-c'
])
})
it('draws no divider in a short list', () => {
const all = [root, ...forks.slice(2)]
expect(ids(buildWorkspaceHierarchy(all, 'me@x.com'))).toEqual([
'prod',
'wm-fork-y',
'wm-fork-z',
'wm-fork-c'
])
})
})
+55 -13
View File
@@ -6,14 +6,26 @@ export interface WorkspaceHierarchyItem {
isForked: boolean
parentName?: string
hasChildren: boolean
/** First fork after the caller's own forks among its siblings; pickers draw a divider above it. */
separatorBefore?: boolean
}
/** Sibling forks below this count stay undivided: a divider in a short list is noise. */
const OWNED_FORKS_SEPARATOR_MIN_SIBLINGS = 5
/**
* Builds a hierarchical structure from a flat array of workspaces.
* Supports unlimited nesting levels (fork of fork of fork...).
* Returns a flattened array with hierarchy metadata for easy rendering.
*
* With `currentUserEmail`, the forks that user created lead each sibling group (after the dev
* workspace). When every admin and developer is added to new forks, a family lists everyone's forks,
* and without this the user's own are scattered alphabetically among them.
*/
export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceHierarchyItem[] {
export function buildWorkspaceHierarchy(
workspaces: UserWorkspace[],
currentUserEmail?: string | null
): WorkspaceHierarchyItem[] {
if (!workspaces || workspaces.length === 0) {
return []
}
@@ -45,12 +57,15 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
const result: WorkspaceHierarchyItem[] = []
const isOwnedFork = (w: UserWorkspace) => !w.is_dev_workspace && isForkOwner(w, currentUserEmail)
// Recursively build the hierarchy
function addWorkspaceAndChildren(
workspace: UserWorkspace,
depth: number,
isForked: boolean,
parentName?: string
parentName?: string,
separatorBefore?: boolean
) {
// Add the current workspace
result.push({
@@ -58,19 +73,31 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
depth,
isForked,
parentName,
hasChildren: hasChildrenSet.has(workspace.id)
hasChildren: hasChildrenSet.has(workspace.id),
separatorBefore
})
// Add its children: the canonical dev workspace first, then throwaway forks by name.
const children = childrenMap.get(workspace.id) || []
children
.sort((a, b) => {
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
return a.name.localeCompare(b.name)
})
.forEach((child) => {
addWorkspaceAndChildren(child, depth + 1, true, workspace.name)
})
// Add its children: the canonical dev workspace first, then the user's own forks, then the
// other forks, each by name.
const children = (childrenMap.get(workspace.id) || []).sort((a, b) => {
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
if (isOwnedFork(a) !== isOwnedFork(b)) return isOwnedFork(a) ? -1 : 1
return a.name.localeCompare(b.name)
})
const forkCount = children.filter((c) => !c.is_dev_workspace).length
const firstOtherFork =
forkCount >= OWNED_FORKS_SEPARATOR_MIN_SIBLINGS && children.some(isOwnedFork)
? children.find((c) => !c.is_dev_workspace && !isOwnedFork(c))
: undefined
children.forEach((child) => {
addWorkspaceAndChildren(
child,
depth + 1,
true,
workspace.name,
child === firstOtherFork || undefined
)
})
}
// Process root workspaces (sorted by name for consistency)
@@ -89,6 +116,21 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
return result
}
/**
* The hierarchy rows below `rootId` (the root itself excluded), in the order and with the depths
* `buildWorkspaceHierarchy` gives them.
*/
export function workspaceSubtreeRows(
rootId: string,
hierarchy: WorkspaceHierarchyItem[]
): WorkspaceHierarchyItem[] {
const at = hierarchy.findIndex((h) => h.workspace.id === rootId)
if (at < 0) return []
const rootDepth = hierarchy[at].depth
const end = hierarchy.findIndex((h, i) => i > at && h.depth <= rootDepth)
return hierarchy.slice(at + 1, end < 0 ? undefined : end)
}
/**
* Helper function to get the indentation padding based on depth.
* Each level adds 24px of left padding.