Compare commits

...
Author SHA1 Message Date
Guilhem LemouelandClaude Opus 5 b998b0e151 fix: show the fork blocker inline and share the gate with the session bar
Review follow-ups: the reason was pointer-only (a native title on a row that
can't take focus), the sidebar disagreed with the scope picker on a prod that
has a dev workspace, and a failed premium fetch claimed the workspace wasn't
paying for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq84eSViYocWFKfqvzdpR
2026-09-02 20:08:32 +02:00
Guilhem LemouelandClaude Opus 5 80164a9177 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq84eSViYocWFKfqvzdpR
2026-09-02 19:59:34 +02:00
4 changed files with 134 additions and 43 deletions
@@ -11,9 +11,14 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import SessionStatusPopover from './SessionStatusPopover.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { maybePremium, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { canCreateFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import {
maybePremium,
premiumFetchFailed,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { forkBlockedReason } from '$lib/utils/editInFork'
import { sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import SessionDiffDrawer from './SessionDiffDrawer.svelte'
@@ -60,10 +65,13 @@
)
const isFork = $derived(!!parentWorkspaceId)
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. On cloud,
// forking is a premium-only feature (backend caps it per paid seat).
// Same blockers the fork dropdowns explain, minus their dev-workspace relaxation: this gates
// whether the bar renders at all, so it needs the answer for the active workspace itself.
const forksAllowed = $derived(
(!isCloudHosted() || $maybePremium) && canCreateFork($userStore) && $workspaceStore !== 'admins'
!forkBlockedReason($userStore, $workspaceStore, {
premium: $maybePremium,
premiumUnknown: $premiumFetchFailed
})
)
const runtime = $derived(getRuntime(session.id))
@@ -3,6 +3,7 @@
import {
enterpriseLicense,
maybePremium,
premiumFetchFailed,
superadmin,
userStore,
userWorkspaces,
@@ -17,7 +18,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 +28,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 +169,21 @@
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,
premiumUnknown: $premiumFetchFailed,
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 +191,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<ForkBlockedReason | undefined>(
forkGateReason ??
(ceWorkspaceCapReached && !onRequestCreateFork
? {
note: 'Workspace limit reached',
detail: `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 +565,18 @@
<span>{createForkLabel}</span>
</button>
{/if}
{:else if showForkUpsell}
{:else if forkAffordanceOpen && createForkBlocked}
<div class="my-1 border-t border-border-light shrink-0"></div>
<div
class={`${rowBase} opacity-60 cursor-not-allowed`}
class="px-3 py-1.5 flex flex-col gap-0.5 opacity-60 cursor-not-allowed"
aria-disabled="true"
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."
>
<Plus size={14} class="shrink-0 text-tertiary" />
<span>{createForkLabel}</span>
<span class="ml-auto shrink-0 text-2xs text-tertiary"> Workspace limit reached </span>
<div class="flex flex-row gap-2 items-center text-xs font-normal text-primary">
<Plus size={14} class="shrink-0 text-tertiary" />
<span>{createForkLabel}</span>
<span class="ml-auto shrink-0 text-2xs text-tertiary">{createForkBlocked.note}</span>
</div>
<span class="text-2xs text-tertiary pl-6">{createForkBlocked.detail}</span>
</div>
{/if}
{#if settingsHref}
@@ -3,6 +3,7 @@
import {
isPremiumStore,
maybePremium,
premiumFetchFailed,
superadmin,
userStore,
userWorkspaces,
@@ -33,11 +34,12 @@
import type { MenubarBuilders } from '@melt-ui/svelte'
import {
buildWorkspaceHierarchy,
findCanonicalDevWorkspace,
findWorkspaceAncestors,
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'
@@ -112,11 +114,17 @@
}
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)
// Gate for the "Workspace fork" entry pinned below the list. When forking is unavailable the
// entry stays, disabled and carrying the reason, rather than disappearing. The global fork modal
// carries its own base-workspace picker, so a canonical dev workspace is a fork base like it is
// in the scope header's picker — judging it without one would disable the entry here while that
// picker offers the same fork one panel away.
const forkBlocked = $derived(
forkBlockedReason($userStore, $workspaceStore, {
premium: $maybePremium,
premiumUnknown: $premiumFetchFailed,
hasDevWorkspace: !!findCanonicalDevWorkspace($workspaceStore, $userWorkspaces)
})
)
const familyWorkspaces = $derived.by(() => {
if (strictWorkspaceSelect) return hierarchy
@@ -375,7 +383,7 @@
</div>
{/each}
</div>
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if isCloudHosted() || $superadmin}
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
@@ -383,7 +391,24 @@
Workspace
</MenuItem>
{/if}
{#if canForkHere}
{#if forkBlocked}
<!-- Kept visible so the reason forking is unavailable is readable here, rather than
leaving the entry to silently vanish. Styled disabled rather than natively
disabled, like the user-disabled workspace rows above, so the row still reads
as menu content. -->
<MenuItem
class={twMerge(itemClass, 'flex-col gap-0.5 opacity-60 cursor-not-allowed')}
onClick={(e) => e.preventDefault()}
{item}
>
<div class="flex flex-row gap-2 items-center w-full">
<Plus size={16} />
Workspace fork
<span class="ml-auto shrink-0 text-2xs text-tertiary">{forkBlocked.note}</span>
</div>
<span class="text-2xs text-tertiary text-left w-full pl-6">{forkBlocked.detail}</span>
</MenuItem>
{:else}
<MenuItem
class={itemClass}
onClick={() => (globalForkModal.val = { opened: true })}
+51
View File
@@ -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,56 @@ export function canCreateFork(user: UserExt | undefined): boolean {
)
}
/**
* Why a create-fork entry is disabled: a short right-hand note and the sentence explaining it. Both
* render as visible text — a disabled row can't take focus and a native `title` never opens on
* touch, so a tooltip would keep the reason from the users most likely to be stuck on it.
*/
export type ForkBlockedReason = { note: string; detail: 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) and `premiumUnknown` from
* `premiumFetchFailed`, which is the half of `maybePremium` that must not be reported as "you don't
* pay for this"; `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,
premiumUnknown = false,
hasDevWorkspace = false
}: { premium: boolean; premiumUnknown?: boolean; hasDevWorkspace?: boolean }
): ForkBlockedReason | undefined {
if (workspaceId === 'admins')
return {
note: 'Not forkable',
detail: 'The admins workspace cannot be forked. Switch to another workspace first.'
}
if (isCloudHosted() && !premium)
return premiumUnknown
? {
note: 'Plan unknown',
detail:
"This workspace's plan could not be checked, and forking needs a paid plan. Reload to try again."
}
: {
note: 'Paid plans only',
detail: 'Forking a workspace needs a paid plan. Upgrade this workspace to create forks.'
}
if (!canCreateFork(user) && !hasDevWorkspace)
return {
note: 'Disabled',
detail:
'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':