feat: add a navigation picker after the breadcrumb's last segment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-09 07:45:07 -07:00
co-authored by Claude Opus 5.5
parent 8b9222d778
commit 43aab55bfb
3 changed files with 152 additions and 33 deletions
@@ -0,0 +1,98 @@
<!--
@component
The chevron after the breadcrumb's last segment: it opens the same picker a session's preview tab
uses — the workspace pages, then the folders with their flows, scripts and apps — and picking a
row navigates the app there.
It lands where the page is: an item's folder with the item highlighted, or a page highlighted at
the root, so moving to a sibling is one step rather than a drill from the top.
-->
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import { goto } from '$app/navigation'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import PreviewRouterPicker, { type Scope } from './sessions/PreviewRouterPicker.svelte'
import { pageKey, type PreviewTarget } from './sessions/previewRouter'
import { editPathFor, leafKeyFor, viewPathFor } from './workspacePicker'
import { base } from '$lib/base'
import type { PageHeaderItem } from './pageHeaderRegistry.svelte'
let {
item,
pagePath,
actingWorkspaceId,
iconSize = 14
}: {
/** The item the page is about, to land in its folder. */
item?: PageHeaderItem
/** The route of a workspace page, to highlight it among the pages. */
pagePath?: string
/** Load from, and navigate within, this workspace rather than the one the app points at. */
actingWorkspaceId?: string
iconSize?: number
} = $props()
let isOpen = $state(false)
// The tree files a path under its `f/<folder>` or `u/<user>` scope, then one branch per
// subfolder, each keyed by its full path — so the item's own folder is its path minus the name.
const initialScope = $derived.by<Scope>(() => {
const parts = item?.path?.split('/') ?? []
return parts.length >= 3 ? { kind: 'all', dir: parts.slice(0, -1).join('/') } : undefined
})
const initialHighlight = $derived(
item?.kind && item.path
? leafKeyFor(item.kind, item.path)
: pagePath
? pageKey(pagePath)
: undefined
)
// A page acting on another workspace than the app's (a session in a fork) keeps acting on it:
// the route reads `?workspace=` before the store.
function withWorkspace(href: string): string {
if (!actingWorkspaceId) return href
const sep = href.includes('?') ? '&' : '?'
return `${href}${sep}workspace=${encodeURIComponent(actingWorkspaceId)}`
}
function hrefFor(target: PreviewTarget): string | undefined {
if (target.type === 'page') return target.href
if (target.type === 'item') {
const path = target.mode === 'view' ? viewPathFor(target.item) : editPathFor(target.item)
return `${base}${path}`
}
return undefined
}
function pick(target: PreviewTarget) {
isOpen = false
const href = hrefFor(target)
if (href) goto(withWorkspace(href))
}
</script>
<Popover
placement="bottom-start"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
bind:isOpen
openFocus="[data-workspace-picker-search]"
contentClasses="flex flex-col overflow-hidden"
class="shrink-0 flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
triggerAttrs={{ 'aria-label': 'Navigate to', title: 'Navigate to' }}
>
{#snippet trigger()}
<ChevronDown size={iconSize} class="flex-shrink-0" />
{/snippet}
{#snippet content()}
<PreviewRouterPicker
{initialScope}
{initialHighlight}
workspaceId={actingWorkspaceId}
onPick={pick}
/>
{/snippet}
</Popover>
@@ -18,7 +18,7 @@ not this component) — so the lead can run the whole line.
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
@@ -31,7 +31,7 @@ not this component) — so the lead can run the whole line.
import { page } from '$app/state'
import { navPageFor } from './sidebar/navPages'
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
import { KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
@@ -236,6 +236,14 @@ not this component) — so the lead can run the whole line.
{/if}
{/snippet}
{#snippet navPicker()}
<!-- The page's own name is the last segment, so the chevron after it moves to a sibling page or
item. Not in a session's preview band, which must not lead out of the host's workspace. -->
{#if !nameOnly}
<BreadcrumbNavPicker {item} pagePath={routePage?.path} {actingWorkspaceId} iconSize={ICON} />
{/if}
{/snippet}
{#snippet slash(extra = '')}
<!-- `extra` tops the workspace part's own 4px gap up to the 6px the breadcrumb puts around its
other separators, so the slash before a fork is spaced like every other slash on the line. -->
@@ -307,10 +315,10 @@ not this component) — so the lead can run the whole line.
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
@@ -361,6 +369,7 @@ not this component) — so the lead can run the whole line.
{@render pathLabel()}
</button>
{/if}
{#if itemIsLast}{@render navPicker()}{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
<span
@@ -386,40 +395,47 @@ not this component) — so the lead can run the whole line.
</span>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
<div class="flex items-center min-w-0">
<span
class={twMerge(SEGMENT, LAST, 'hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{@render sectionKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{@render navPicker()}
</div>
{:else if section}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
<div class="flex items-center min-w-0">
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{@render navPicker()}
</div>
{#if afterName}{@render afterName()}{/if}
{:else if routePage}
{@const RouteIcon = routePage.icon}
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
whole, so the workspace and fork names give way first when the bar is full. -->
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
<div class="flex items-center shrink-0">
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{@render navPicker()}
</div>
{#if afterName}{@render afterName()}{/if}
{/if}
</nav>
@@ -12,6 +12,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte'
import { BAR_HEIGHT, pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
@@ -179,6 +180,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{:else}
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
{/if}
<!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. -->
{#if !navHidden}
<BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} />
{/if}
</div>
{/if}