From c1a561f5ec091de0ae2de12ee811ca628c58c9f5 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 14 Oct 2026 19:10:25 +0200 Subject: [PATCH] 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) * fix: keep focus in workspace search when the menu opens from the keyboard Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reach workspace picker actions from the search field by keyboard Co-Authored-By: Claude Opus 5.5 (1M context) * fix: ignore IME keys and reset workspace search state only on input Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reach the cloud upgrade entry from the workspace search by keyboard Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../sidebar/WorkspacePickerBody.svelte | 575 ++++++++++++------ 1 file changed, 395 insertions(+), 180 deletions(-) diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index 880aeb5416..03a457b630 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -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 { const withForks = new Set() 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() + 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() + 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(`[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('[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 - {:else} - + + {:else} + - + + {/if} + + {/each} + + {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} +
+ {#if isCloudHosted() || $superadmin} +
+ + + Workspace + +
+ {/if} + {#if canForkHere} +
+ (globalForkModal.val = { opened: true })} + {item} + > + + Workspace fork + +
{/if}
- {/each} + {/if} + {#if canManageWorkspace && !strictWorkspaceSelect} +
+ + + {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) + ? activeWorkspace.id + : activeWorkspace?.name) ?? $workspaceStore} settings + +
+ {/if} + {#if !strictWorkspaceSelect} +
+ clearWorkspaceFromStorage()} + class={actionClass('all')} + {item} + > + All workspaces + +
+ {/if} - {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + {#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
- {#if isCloudHosted() || $superadmin} - - - Workspace - + {#if $workspaceStore != 'demo'} + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + + +
+
+
{/if} - {#if canForkHere} - (globalForkModal.val = { opened: true })} {item}> - - Workspace fork - + {#if $userStore?.is_admin} +
+ { + goto('/workspace_settings?tab=premium') + }} + {item} + > + Upgrade + +
{/if}
{/if} - {#if canManageWorkspace && !strictWorkspaceSelect} -
- - - {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) - ? activeWorkspace.id - : activeWorkspace?.name) ?? $workspaceStore} settings - -
- {/if} - {#if !strictWorkspaceSelect} -
- clearWorkspaceFromStorage()} - class={itemClass} - {item} - > - All workspaces - -
- {/if} -{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect} -
- {#if $workspaceStore != 'demo'} - - {$workspaceUsageStore ?? '—'}/1000 free workspace execs - - {#snippet text()} - {EXECUTIONS_HINT} - {/snippet} - - -
-
-
- {/if} - {#if $userStore?.is_admin} - { - goto('/workspace_settings?tab=premium') - }} - {item} - > - Upgrade - - {/if} -
-{/if} {#if $enterpriseLicense && !strictWorkspaceSelect} {/if}