feat: search the workspace picker dropdown past 8 workspaces (#11761)

* feat: add search to the workspace picker dropdown past 8 workspaces

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

* fix: keep focus in workspace search when the menu opens from the keyboard

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

* fix: reach workspace picker actions from the search field by keyboard

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

* fix: ignore IME keys and reset workspace search state only on input

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

* fix: reach the cloud upgrade entry from the workspace search by keyboard

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 17:10:25 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 717162eab4
commit c1a561f5ec
@@ -13,9 +13,12 @@
workspaceUsageStore,
clearWorkspaceFromStorage,
globalForkModal,
enterpriseLicense
enterpriseLicense,
type UserWorkspace
} from '$lib/stores'
import { Check, ChevronDown, ChevronRight, Plus, Settings } from 'lucide-svelte'
import { Check, ChevronDown, ChevronRight, Plus, Search, Settings } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { SvelteSet } from 'svelte/reactivity'
import { CopyButton } from '$lib/components/common'
@@ -143,15 +146,16 @@
// 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
// last depth-0 row seen.
const familiesWithForks = $derived.by(() => {
function rootsWithForks(rows: typeof hierarchy): Set<string> {
const withForks = new Set<string>()
let rootId: string | undefined
for (const h of hierarchy) {
for (const h of rows) {
if (h.depth === 0) rootId = h.workspace.id
else if (rootId) withForks.add(rootId)
}
return withForks
})
}
const familiesWithForks = $derived(rootsWithForks(hierarchy))
// 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.
@@ -169,16 +173,54 @@
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
})
const SEARCH_THRESHOLD = 8
const showSearch = $derived(($userWorkspaces?.length ?? 0) > SEARCH_THRESHOLD)
let searchFilter = $state('')
let searchMatches: (UserWorkspace & { marked?: string })[] | undefined = $state()
const searching = $derived(showSearch && searchFilter.trim() !== '')
const markedNames = $derived(
new Map(searching ? searchMatches?.map((w) => [w.id, w.marked]) : undefined)
)
// While searching, every family is open: a match is listed under the ancestors that lead to
// it, so a fork still reads as part of its family.
const searchedRows = $derived.by(() => {
const matched = new Set(searchMatches?.map((w) => w.id))
const keep = new Set<string>()
const path: string[] = []
for (const h of hierarchy) {
path.length = h.depth
path.push(h.workspace.id)
if (matched.has(h.workspace.id)) path.forEach((id) => keep.add(id))
}
return hierarchy.filter((h) => keep.has(h.workspace.id))
})
// A search opens every family that has a matching fork; the chevron can still close one. Kept
// apart from expandedFamilies so clearing the search restores the families as they were.
const collapsedInSearch = new SvelteSet<string>()
function isExpanded(rootId: string): boolean {
return searching ? !collapsedInSearch.has(rootId) : expandedFamilies.has(rootId)
}
function toggleFamily(rootId: string) {
const set = searching ? collapsedInSearch : expandedFamilies
if (set.has(rootId)) set.delete(rootId)
else set.add(rootId)
}
// Roots with at least one fork among the rows the list can show right now.
const expandableFamilies = $derived(searching ? rootsWithForks(searchedRows) : familiesWithForks)
const familyWorkspaces = $derived.by(() => {
let rootId: string | undefined
const rows = searching ? searchedRows : hierarchy
const visible = strictWorkspaceSelect
? hierarchy
: hierarchy.filter((h) => {
? rows
: rows.filter((h) => {
if (h.depth === 0) {
rootId = h.workspace.id
return true
}
return !!rootId && expandedFamilies.has(rootId)
return !!rootId && isExpanded(rootId)
})
// The family the user is standing in leads the list, its forks travelling with it: the
// workspace they are in is the one they look for first, and the one the tick is on.
@@ -195,12 +237,112 @@
return [active, ...families].flatMap((f) => f.rows)
})
// With the search field shown, focus stays in it: the arrows move this highlight rather than
// melt's roving focus, which would take the keys away from the field. Until the user arrows or
// hovers, it rests on the best-ranked visible match (not the first row, which may only be a
// match's ancestor), so Enter's target is always the highlighted row. The highlight walks every
// element carrying `data-nav-id` — workspace rows, then the actions below them — in DOM order.
const ACTION_PREFIX = 'action:'
let rootEl: HTMLDivElement | undefined = $state()
let pickedId: string | undefined = $state()
const navIds = $derived(
familyWorkspaces.filter((h) => !h.workspace.disabled).map((h) => h.workspace.id)
)
const highlightedId = $derived(
!showSearch
? undefined
: pickedId && (pickedId.startsWith(ACTION_PREFIX) || navIds.includes(pickedId))
? pickedId
: searching
? searchMatches?.find((w) => navIds.includes(w.id))?.id
: undefined
)
function actionNavId(action: string): string | undefined {
return showSearch ? ACTION_PREFIX + action : undefined
}
function actionClass(action: string): string {
return twMerge(itemClass, highlightedId === ACTION_PREFIX + action && 'bg-surface-hover')
}
function navElement(id: string): HTMLElement | null | undefined {
return rootEl?.querySelector<HTMLElement>(`[data-nav-id="${CSS.escape(id)}"]`)
}
function moveHighlight(step: 1 | -1) {
const order = [...(rootEl?.querySelectorAll('[data-nav-id]') ?? [])].map(
(el) => el.getAttribute('data-nav-id')!
)
if (order.length === 0) return
const at = highlightedId ? order.indexOf(highlightedId) : -1
const next =
at === -1
? step === 1
? 0
: order.length - 1
: Math.min(order.length - 1, Math.max(0, at + step))
pickedId = order[next]
navElement(order[next])?.scrollIntoView({ block: 'nearest' })
}
function onSearchKeydown(e: KeyboardEvent) {
// An IME uses arrows, Enter and Escape to pick and confirm a candidate; those keys are its own.
if (e.isComposing || e.keyCode === 229) {
e.stopPropagation()
return
}
if (e.key === 'Escape') return
e.stopPropagation()
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(e.key === 'ArrowDown' ? 1 : -1)
} else if (e.key === 'Enter') {
const target = highlightedId
if (!target) return
e.preventDefault()
if (target.startsWith(ACTION_PREFIX)) {
navElement(target)?.querySelector<HTMLElement>('[role="menuitem"]')?.click()
return
}
closeMenu?.()
void tick().then(() => toggleSwitchWorkspace(target))
}
}
// A new query starts from its own best match with every matching family open.
function onSearchInput() {
pickedId = undefined
collapsedInSearch.clear()
}
// melt focuses an item on pointermove and its menu on pointerleave; stopped here, before they
// reach the item, so hovering the list leaves focus in the search field.
function keepSearchFocus(e: PointerEvent) {
if (!showSearch) return
e.stopPropagation()
if (e.type === 'pointermove') {
const id = (e.target as HTMLElement | null)
?.closest('[data-nav-id]')
?.getAttribute('data-nav-id')
if (id) pickedId = id
}
}
// melt still moves focus onto an item in other cases, the first one when the menu is opened from
// the keyboard among them; with the search shown, focus belongs to the field.
let searchWrapEl: HTMLDivElement | undefined = $state()
function returnFocusToSearch(e: FocusEvent) {
if (!showSearch || !(e.target as HTMLElement | null)?.closest('[role="menuitem"]')) return
searchWrapEl?.querySelector('input')?.focus()
}
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
// stamps data-highlighted on the item; the row wrapper carries the workspace
// id). Capture phase, so melt's menubar left/right (adjacent-menu switching)
// doesn't fire when the keypress means expansion here.
function onExpandKeydown(e: KeyboardEvent) {
if (strictWorkspaceSelect) return
if (strictWorkspaceSelect || e.target instanceof HTMLInputElement) return
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
// Scoped inside a row wrapper: the menubar trigger also carries
// data-highlighted while its menu is open.
@@ -236,215 +378,288 @@
// font-normal is explicit: href-less MenuItems render as <button>, which the
// global stylesheet makes semibold, unlike the <a> the href entries get.
const itemClass =
'text-primary font-normal w-full flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
'text-primary font-normal w-full flex flex-row gap-2 px-4 py-2 text-xs focus:outline-none hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
</script>
<svelte:window onkeydowncapture={onExpandKeydown} />
<div class="divide-y" role="none">
<!-- The list scrolls internally so the sections below (+ Workspace,
<div
bind:this={rootEl}
role="none"
onpointermovecapture={keepSearchFocus}
onpointerleavecapture={keepSearchFocus}
onfocusincapture={returnFocusToSearch}
>
<div class="divide-y" role="none">
{#if showSearch}
<SearchItems
filter={searchFilter}
items={$userWorkspaces}
bind:filteredItems={searchMatches}
f={(w) => w.name + ' (' + w.id + ')'}
/>
<!-- Keys typed here must not reach melt: its menu typeahead would focus the first item
starting with the typed letter. A native listener, because Svelte delegates `onkeydown`
to the root, which runs after melt's listener on the menu element. -->
<div
bind:this={searchWrapEl}
class="px-2 pt-1 pb-2"
role="none"
{@attach (el) => {
el.addEventListener('keydown', onSearchKeydown)
return () => el.removeEventListener('keydown', onSearchKeydown)
}}
>
<div class="relative">
<TextInput
bind:value={searchFilter}
size="sm"
inputProps={{
placeholder: 'Search workspaces...',
autofocus: true,
oninput: onSearchInput
}}
class="!pr-8"
/>
<Search
size={14}
class="text-secondary absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none"
/>
</div>
</div>
{/if}
<!-- 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, 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>
<div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]">
{#if searching && familyWorkspaces.length === 0}
<div class="px-3 py-2 text-xs text-secondary">No workspaces match your search</div>
{/if}
<!-- Selection is a trailing tick (single-choice picker convention). A
{#each familyWorkspaces as { workspace, depth, isForked, parentName, separatorBefore } (workspace.id)}
{@const isActive = $workspaceStore === workspace.id}
{@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined}
<!-- A search filters out sibling forks, so the divider between the user's own forks
and the others would mark the wrong boundary. -->
{#if separatorBefore && !searching}
<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
selection; expanding moves the tick to the fork itself. -->
{@const isSelected =
isActive ||
(!strictWorkspaceSelect &&
depth === 0 &&
lineageRoot?.id === workspace.id &&
!expandedFamilies.has(workspace.id))}
{@const expandable =
!strictWorkspaceSelect && depth === 0 && familiesWithForks.has(workspace.id)}
<!-- The expand chevron sits OUTSIDE the melt item: melt activates items
{@const isSelected =
isActive ||
(!strictWorkspaceSelect &&
depth === 0 &&
lineageRoot?.id === workspace.id &&
!isExpanded(workspace.id) &&
(!searching || searchedRows.some((h) => h.workspace.id === $workspaceStore)))}
{@const expandable =
!strictWorkspaceSelect && depth === 0 && expandableFamilies.has(workspace.id)}
<!-- The expand chevron sits OUTSIDE the melt item: melt activates items
via document-level handlers, so a nested button can't stop the row's
navigate-and-close with stopPropagation. As a sibling it toggles the
family without selecting the row or closing the menu. -->
<!-- Hover/keyboard-highlight backgrounds live on the wrapper (the
<!-- Hover/keyboard-highlight backgrounds live on the wrapper (the
highlight via :has(), since melt puts data-highlighted on the item) so
they span the full row, chevron included; the chevron keeps only its
own hover tint. The active workspace is marked by its row's check
icon, not a background or text accent. -->
<div
data-workspace-id={workspace.id}
class={twMerge(
'group flex items-center min-w-0 w-full',
workspace.disabled
? ''
: 'hover:bg-surface-hover [&:has([data-highlighted])]:bg-surface-hover'
)}
>
<MenuItem
<div
data-workspace-id={workspace.id}
data-nav-id={showSearch && !workspace.disabled ? workspace.id : undefined}
class={twMerge(
'text-xs min-w-0 flex-1 overflow-hidden flex flex-col py-1.5 px-3',
workspace.disabled && 'opacity-50 cursor-not-allowed',
isActive ? 'cursor-default' : workspace.disabled ? '' : 'cursor-pointer'
'group flex items-center min-w-0 w-full',
workspace.disabled
? ''
: showSearch
? highlightedId === workspace.id && 'bg-surface-hover'
: 'hover:bg-surface-hover [&:has([data-highlighted])]:bg-surface-hover'
)}
href={workspace.disabled ? undefined : workspaceHref(workspace.id)}
onClick={(e) => onWorkspaceItemClick(e, workspace)}
{item}
>
<div class="flex items-center justify-between gap-2 min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
<WorkspaceIcon
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
{parentName}
padding="p-1"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0">
<div
class="truncate text-left text-xs font-normal {forkAccent
? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]'
: 'text-primary'}"
style={forkAccent}
title={workspace.name}
>
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
<MenuItem
class={twMerge(
'text-xs min-w-0 flex-1 overflow-hidden flex flex-col py-1.5 px-3 focus:outline-none',
workspace.disabled && 'opacity-50 cursor-not-allowed',
isActive ? 'cursor-default' : workspace.disabled ? '' : 'cursor-pointer'
)}
href={workspace.disabled ? undefined : workspaceHref(workspace.id)}
onClick={(e) => onWorkspaceItemClick(e, workspace)}
{item}
>
<div class="flex items-center justify-between gap-2 min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
<WorkspaceIcon
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
{parentName}
padding="p-1"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0">
<div
class="truncate text-left text-xs font-normal {forkAccent
? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]'
: 'text-primary'}"
style={forkAccent}
title={workspace.name}
>
{#if markedNames.get(workspace.id)}
<!-- SearchItems escapes the text; only its <mark> tags are markup. -->
{@html markedNames.get(workspace.id)}
{:else}
{workspace.name}
{/if}{workspace.disabled ? ' (user disabled)' : ''}
</div>
{#if workspace.is_dev_workspace}
<DevWorkspaceBadge
label={workspace.dev_workspace_label}
color={workspace.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{/if}
</div>
{#if workspace.is_dev_workspace}
<DevWorkspaceBadge
label={workspace.dev_workspace_label}
color={workspace.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{#if ambiguousNames.has(workspace.name) && !markedNames.has(workspace.id)}
<div class="truncate text-left text-2xs text-tertiary" title={workspace.id}>
{workspace.id}
</div>
{/if}
</div>
{#if ambiguousNames.has(workspace.name)}
<div class="truncate text-left text-2xs text-tertiary" title={workspace.id}>
{workspace.id}
</div>
{/if}
</div>
{#if isSelected}
<Check size={14} class="shrink-0 ml-2 text-accent" />
{/if}
</div>
{#if isSelected}
<Check size={14} class="shrink-0 ml-2 text-accent" />
{/if}
</div>
</MenuItem>
<!-- Hover-revealed like the fork picker's rows; a flex sibling of the melt
</MenuItem>
<!-- Hover-revealed like the fork picker's rows; a flex sibling of the melt
item (nested buttons are invalid HTML) that always occupies its slot so
nothing shifts on hover. -->
<div
class="shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
>
<CopyButton value={workspace.id} title={`Copy id: ${workspace.id}`} />
</div>
{#if expandable}
{@const expanded = expandedFamilies.has(workspace.id)}
<button
type="button"
title={expanded ? 'Hide forks' : 'Show forks'}
aria-expanded={expanded}
class="shrink-0 mr-1 px-2 self-stretch flex items-center rounded text-tertiary hover:bg-surface-hover hover:text-primary"
onclick={() => {
if (expanded) expandedFamilies.delete(workspace.id)
else expandedFamilies.add(workspace.id)
}}
<div
class="shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
>
{#if expanded}
<ChevronDown size={14} />
{:else}
<ChevronRight size={14} />
{/if}
</button>
{:else}
<!-- Reserve the expand-chevron's slot on fork-less rows so the hover copy
<CopyButton value={workspace.id} title={`Copy id: ${workspace.id}`} />
</div>
{#if expandable}
{@const expanded = isExpanded(workspace.id)}
<!-- mousedown is cancelled so the click leaves focus in the search field. -->
<button
type="button"
title={expanded ? 'Hide forks' : 'Show forks'}
aria-expanded={expanded}
class="shrink-0 mr-1 px-2 self-stretch flex items-center rounded text-tertiary hover:bg-surface-hover hover:text-primary"
onmousedown={(e) => showSearch && e.preventDefault()}
onclick={() => toggleFamily(workspace.id)}
>
{#if expanded}
<ChevronDown size={14} />
{:else}
<ChevronRight size={14} />
{/if}
</button>
{:else}
<!-- Reserve the expand-chevron's slot on fork-less rows so the hover copy
button lines up with the forked rows' and never sits flush against the
menu's right edge. Matches the chevron's px-2 (30px) + mr-1. -->
<div class="shrink-0 mr-1 w-[30px]" aria-hidden="true"></div>
<div class="shrink-0 mr-1 w-[30px]" aria-hidden="true"></div>
{/if}
</div>
{/each}
</div>
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if isCloudHosted() || $superadmin}
<div role="none" data-nav-id={actionNavId('create')}>
<MenuItem href="/user/create_workspace" class={actionClass('create')} {item}>
<Plus size={16} />
Workspace
</MenuItem>
</div>
{/if}
{#if canForkHere}
<div role="none" data-nav-id={actionNavId('fork')}>
<MenuItem
class={actionClass('fork')}
onClick={() => (globalForkModal.val = { opened: true })}
{item}
>
<Plus size={16} />
Workspace fork
</MenuItem>
</div>
{/if}
</div>
{/each}
{/if}
{#if canManageWorkspace && !strictWorkspaceSelect}
<div class="py-1" role="none" data-nav-id={actionNavId('settings')}>
<MenuItem
href="/workspace_settings?workspace={$workspaceStore}"
class={actionClass('settings')}
{item}
>
<Settings size={16} />
{(activeWorkspace && ambiguousNames.has(activeWorkspace.name)
? activeWorkspace.id
: activeWorkspace?.name) ?? $workspaceStore} settings
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none" data-nav-id={actionNavId('all')}>
<MenuItem
href="/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
class={actionClass('all')}
{item}
>
All workspaces
</MenuItem>
</div>
{/if}
</div>
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if isCloudHosted() || $superadmin}
<MenuItem href="/user/create_workspace" class={itemClass} {item}>
<Plus size={16} />
Workspace
</MenuItem>
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$workspaceUsageStore ?? '—'}/1000 free workspace execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="w-full bg-gray-200 h-1">
<div
class="bg-blue-400 h-1"
style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%"
></div>
</div>
{/if}
{#if canForkHere}
<MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}>
<Plus size={16} />
Workspace fork
</MenuItem>
{#if $userStore?.is_admin}
<div role="none" data-nav-id={actionNavId('upgrade')}>
<MenuItem
class={twMerge(
'text-secondary block font-normal w-full text-left px-4 py-2 text-sm focus:outline-none hover:bg-gray-100 hover:text-gray-900',
'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900',
highlightedId === ACTION_PREFIX + 'upgrade' && 'bg-gray-100 text-gray-900'
)}
onClick={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Upgrade
</MenuItem>
</div>
{/if}
</div>
{/if}
{#if canManageWorkspace && !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem href="/workspace_settings?workspace={$workspaceStore}" class={itemClass} {item}>
<Settings size={16} />
{(activeWorkspace && ambiguousNames.has(activeWorkspace.name)
? activeWorkspace.id
: activeWorkspace?.name) ?? $workspaceStore} settings
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
href="/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
class={itemClass}
{item}
>
All workspaces
</MenuItem>
</div>
{/if}
</div>
{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$workspaceUsageStore ?? '—'}/1000 free workspace execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="w-full bg-gray-200 h-1">
<div
class="bg-blue-400 h-1"
style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%"
></div>
</div>
{/if}
{#if $userStore?.is_admin}
<MenuItem
class={twMerge(
'text-secondary block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900',
'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900'
)}
onClick={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Upgrade
</MenuItem>
{/if}
</div>
{/if}
{#if $enterpriseLicense && !strictWorkspaceSelect}
<MultiplayerMenu />
{/if}