From 80164a917769a2424469ce4e96438bbf6729e25e Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 2 Sep 2026 19:59:34 +0200 Subject: [PATCH] fix: explain in the fork dropdowns why a fork can't be created The create-fork entry disappeared whenever forking was unavailable, leaving no trace of why. Keep it visible, disabled, and carrying the reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014cq84eSViYocWFKfqvzdpR --- .../sessions/WorkspaceFamilyPicker.svelte | 55 ++++++++++--------- .../components/sidebar/WorkspaceMenu.svelte | 28 +++++++--- frontend/src/lib/utils/editInFork.ts | 36 ++++++++++++ 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte index 6501dc8d3e..d60e4f893f 100644 --- a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -17,7 +17,7 @@ buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' - import { canCreateFork } from '$lib/utils/editInFork' + import { forkBlockedReason, type ForkBlockedReason } from '$lib/utils/editInFork' import { forkAccentStyle } from '$lib/utils/forkColor' import { getUserExt } from '$lib/user' import { WorkspaceService } from '$lib/gen' @@ -27,7 +27,6 @@ canUserBypassRuleKindInRulesets } from '$lib/workspaceProtectionRules.svelte' import { resource } from 'runed' - import { isCloudHosted } from '$lib/cloud' import { random_adj } from '$lib/components/random_positive_adjetive' import DropdownV2 from '$lib/components/DropdownV2.svelte' import InputError from '$lib/components/InputError.svelte' @@ -169,19 +168,20 @@ rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot ) - // Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking - // is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace - // (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and - // devOfRoot only resolves when the user is a member of it. - const forksGateOpen = $derived( - (!isCloudHosted() || $maybePremium) && - $workspaceStore !== 'admins' && - (canCreateFork($userStore) || !!devOfRoot) + // Why forking is unavailable here (admins workspace, non-premium cloud, or a protection rule), or + // undefined when it is available. DisableWorkspaceForking on the active workspace (a locked prod) + // doesn't apply when there's a dev to fork from instead — the dev isn't locked, and devOfRoot only + // resolves when the user is a member of it. + const forkGateReason = $derived( + forkBlockedReason($userStore, $workspaceStore, { + premium: $maybePremium, + hasDevWorkspace: !!devOfRoot + }) ) // A fork is a new workspace, so it's subject to the community-edition cap on // the number of non-'admins' workspaces (backend _check_nb_of_workspaces, // run only on community builds). An enterprise license lifts the cap. We - // mirror the backend count with the client-side workspace list to hide the + // mirror the backend count with the client-side workspace list to disable the // affordance once the cap is reached; the server still enforces the real // (instance-wide) check on commit, so this is purely UX. const CE_MAX_NON_ADMIN_WORKSPACES = 2 @@ -189,20 +189,24 @@ const ceWorkspaceCapReached = $derived( !$enterpriseLicense && nonAdminWorkspaceCount >= CE_MAX_NON_ADMIN_WORKSPACES ) - // The interactive create-fork row is shown unless the cap is reached; - // otherwise (structural gate open but cap hit) we surface a disabled row - // explaining the limit — never stage a fork the backend would reject. + // Structural: the consumer wired up a create path at all, and there's a family to fork from. The + // row is absent only in that case — every other blocker keeps it visible and names itself. const forkAffordanceOpen = $derived( - allowCreateFork && forksGateOpen && (!!onCreateFork || !!onRequestCreateFork) && !!root + allowCreateFork && (!!onCreateFork || !!onRequestCreateFork) && !!root ) - // The upsell (CE workspace cap) only applies to in-place inline creation; - // onRequestCreateFork delegates to a flow that enforces its own limits. - const showCreateFork = $derived( - forkAffordanceOpen && (!ceWorkspaceCapReached || !!onRequestCreateFork) - ) - const showForkUpsell = $derived( - forkAffordanceOpen && ceWorkspaceCapReached && !onRequestCreateFork + // Blocker for the create-fork row: the gate above, else the CE workspace cap. The cap only applies + // to in-place inline creation; onRequestCreateFork delegates to a flow that enforces its own + // limits. Never stage a fork the backend would reject. + const createForkBlocked = $derived( + forkGateReason ?? + (ceWorkspaceCapReached && !onRequestCreateFork + ? { + note: 'Workspace limit reached', + title: `Community edition is limited to ${CE_MAX_NON_ADMIN_WORKSPACES + 1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks.` + } + : undefined) ) + const showCreateFork = $derived(forkAffordanceOpen && !createForkBlocked) let dropdownOpen = $state(false) let creatingFork = $state(false) @@ -559,17 +563,16 @@ {createForkLabel} {/if} - {:else if showForkUpsell} + {:else if forkAffordanceOpen && createForkBlocked}
{createForkLabel} - Workspace limit reached + {createForkBlocked.note}
{/if} {#if settingsHref} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 1555884eff..d2ae993249 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -37,7 +37,7 @@ findWorkspaceRoot, isForkOwner } from '$lib/utils/workspaceHierarchy' - import { canCreateFork } from '$lib/utils/editInFork' + import { forkBlockedReason } from '$lib/utils/editInFork' import { getContrastTextColor } from '$lib/utils' import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte' import { devBadgeText } from '$lib/utils/devWorkspaceLabel' @@ -113,10 +113,10 @@ return withForks }) // Gate for the "Workspace fork" entry pinned below the list (the global fork - // modal carries its own base-workspace picker). Hidden on non-premium cloud, - // in the admins workspace, or when forking is disabled. - const canForkHere = $derived( - (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore) + // modal carries its own base-workspace picker). When forking is unavailable the + // entry stays, disabled and carrying the reason, rather than disappearing. + const forkBlocked = $derived( + forkBlockedReason($userStore, $workspaceStore, { premium: $maybePremium }) ) const familyWorkspaces = $derived.by(() => { if (strictWorkspaceSelect) return hierarchy @@ -375,7 +375,7 @@ {/each} - {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + {#if !strictWorkspaceSelect}
{#if isCloudHosted() || $superadmin} @@ -383,7 +383,21 @@ Workspace {/if} - {#if canForkHere} + {#if forkBlocked} + + + {:else} (globalForkModal.val = { opened: true })} diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index 4e0344471f..f28bc19739 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -13,6 +13,7 @@ import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import { checkItemExists } from '$lib/utils_workspace_deploy' import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte' +import { isCloudHosted } from '$lib/cloud' export type ItemType = 'script' | 'flow' | 'app' | 'raw_app' @@ -55,6 +56,41 @@ export function canCreateFork(user: UserExt | undefined): boolean { ) } +/** Why a create-fork entry is disabled: a short right-hand note, and the tooltip that explains it. */ +export type ForkBlockedReason = { note: string; title: string } + +/** + * The blocker on creating a fork of the given workspace, or undefined when there is none. Callers + * render the create-fork entry disabled and carrying this reason rather than dropping it, so a user + * who can't fork reads why instead of hunting for a missing entry. `premium` comes from + * `maybePremium` (forking is metered per paid seat on cloud); `hasDevWorkspace` relaxes the forking + * rule, since a locked prod's dev workspace is itself forkable. + */ +export function forkBlockedReason( + user: UserExt | undefined, + workspaceId: string | undefined, + { premium, hasDevWorkspace = false }: { premium: boolean; hasDevWorkspace?: boolean } +): ForkBlockedReason | undefined { + if (workspaceId === 'admins') + return { + note: 'Not forkable', + title: 'The admins workspace cannot be forked. Switch to another workspace to create a fork.' + } + if (isCloudHosted() && !premium) + return { + note: 'Paid plans only', + title: + 'Forking a workspace is available on paid plans. Upgrade this workspace to create forks.' + } + if (!canCreateFork(user) && !hasDevWorkspace) + return { + note: 'Disabled', + title: + 'A protection rule disables forking of this workspace. A workspace admin can change it in the workspace settings.' + } + return undefined +} + function editPathFor(itemType: ItemType, itemPath: string): string { switch (itemType) { case 'script':