feat: one page header across routes, with the workspace picker in it

Every route now wears the same bar above its content: the sidebar toggle, the
workspace picker, and whatever the route registers — an item's breadcrumb and
summary, or a section name — with the route's own buttons at the far end. The
bar's row is level with the sidebar's own top row, which the workspace ⇄
sessions switch now owns, so the two read as one band.

A route fills the bar by mounting PageHeaderContent, which registers a getter
rather than a snapshot: the layout calls it while rendering, so a path or
summary the page edits reaches the bar without an effect keeping two copies in
step. Nothing registers yet; the routes follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-22 10:58:36 +02:00
co-authored by Claude Opus 5
parent 0536f36566
commit a5398b71f2
6 changed files with 204 additions and 55 deletions
@@ -58,6 +58,9 @@
/** Workspace whose items the breadcrumb picker lists; defaults to the operating
* workspace (see `useOperatingWorkspace`). */
workspaceId?: string
/** Lay the summary out after the path on one line, for the page header bar, rather than
* stacked under it. */
inline?: boolean
}
let {
@@ -72,7 +75,8 @@
summaryEditable = true,
pathEditable = true,
hidePath = false,
workspaceId
workspaceId,
inline = false
}: Props = $props()
let pathPopoverOpen = $state(false)
@@ -145,7 +149,11 @@
}
</script>
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight">
<div
class="max-w-full group px-2 py-0.5 leading-tight {inline
? 'flex items-center gap-2 min-w-0'
: 'inline-block align-top'}"
>
<!-- Path row -->
{#if !hidePath}
<div class="flex items-center max-w-full text-2xs text-secondary font-mono">
@@ -255,7 +263,7 @@
{/if}
<!-- Summary -->
<div class="max-w-full -mt-[2px]" title={summary}>
<div class="max-w-full {inline ? 'min-w-0' : '-mt-[2px]'}" title={summary}>
<EditableInput
value={summary ?? ''}
placeholder="Add a summary..."
@@ -0,0 +1,69 @@
<!--
@component
The bar every route wears, above the page content and beside the sidebar: the sidebar toggle, the
workspace picker, then whatever the route registered — an item's breadcrumb and summary, or a
section name — and the route's own buttons at the far end.
The row's height matches the sidebar's own header row, so the two read as one band.
-->
<script lang="ts">
import { Menubar } from '$lib/components/meltComponents'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
import { kindKey, KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
interface Props {
/** The sidebar's show/hide control, placed at the very start of the band. */
toggle?: import('svelte').Snippet
}
let { toggle }: Props = $props()
const content = $derived(pageHeader.content)
const item = $derived(content?.item)
const section = $derived(content?.section)
</script>
<div class="flex items-center gap-1 h-12 px-2 border-b shrink-0 min-w-0">
{@render toggle?.()}
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} strictWorkspaceSelect={false} />
{/snippet}
</Menubar>
{#if item}
<EditorHeader
inline
kind={item.kind}
raw_app={item.raw_app}
savedPath={item.savedPath}
workspaceId={item.workspaceId}
onBehalfOfEmail={item.onBehalfOfEmail}
pathEditable={item.pathEditable ?? true}
summaryEditable={item.summaryEditable ?? true}
onNavigate={item.onNavigate}
bind:path={() => item.path, (v) => item.onPathChange?.(v ?? '')}
bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')}
/>
{:else if section}
<nav aria-label="Breadcrumb" class="contents">
<BreadcrumbSegment
label={section.kind ? KIND_LABEL_LOWER[section.kind] : section.label}
isCurrent
initialHighlight={section.kind ? kindKey(section.kind) : undefined}
initialScope={section.kind ? { kind: section.kind } : undefined}
onPick={() => {}}
/>
</nav>
{/if}
{#if content?.actions}
<div class="ml-auto flex items-center gap-2 shrink-0">
{@render content.actions()}
</div>
{/if}
</div>
@@ -0,0 +1,19 @@
<!--
@component
Fills the layout's page header from the route that mounts it: the breadcrumb (an item's path, or a
section name for a list) and the page's own buttons. Renders nothing itself.
Only a route page mounts this. An editor nested in a session pane or a drawer must not take over
the header, so nesting is decided by the caller rather than guessed from context.
-->
<script lang="ts">
import { onMount } from 'svelte'
import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte'
let { item, section, actions }: PageHeaderContent = $props()
onMount(() => {
const id = pageHeader.register(() => ({ item, section, actions }))
return () => pageHeader.release(id)
})
</script>
@@ -21,6 +21,8 @@
transitionClass?: string
isMobile?: boolean
children: any
/** The bar every route wears, rendered above the page content and inside the rail's offset. */
header?: import('svelte').Snippet
onMenuOpen?: () => void
disableAi?: boolean
// Whether this layout loads the workspace AI config. It gates far more than
@@ -41,6 +43,7 @@
transitionClass = 'transition-all ease-in-out duration-200',
isMobile = false,
children,
header,
onMenuOpen,
disableAi,
loadAiConfig = true,
@@ -113,6 +116,7 @@
<main class="flex-1 flex flex-col min-h-0">
<div class="relative w-full flex-1 flex flex-col min-h-0">
{@render burgerRow()}
{@render header?.()}
<div class="flex-1 min-h-0">
{@render children?.()}
</div>
@@ -142,6 +146,7 @@
style:padding-left="{contentPadLeft}rem"
>
{@render burgerRow()}
{@render header?.()}
<div class="flex-1 min-h-0 flex flex-col">
{@render children?.()}
</div>
@@ -0,0 +1,62 @@
import type { Snippet } from 'svelte'
import type { WorkspaceItem, WorkspaceItemKind } from './workspacePicker'
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
export type PageHeaderItem = {
kind: WorkspaceItemKind
path: string | undefined
/** The item's saved path, so a mid-rename breadcrumb and picker stay coherent. */
savedPath?: string
summary?: string
raw_app?: boolean
/** Which workspace the breadcrumb's pickers read (a session editor acts on its fork). */
workspaceId?: string
pathEditable?: boolean
summaryEditable?: boolean
onBehalfOfEmail?: string
onSummaryChange?: (summary: string) => void
onPathChange?: (path: string) => void
onNavigate?: (item: WorkspaceItem) => void
}
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
export type PageHeaderSection = {
label: string
/** Scopes the segment's picker, when the section maps to a kind of item. */
kind?: WorkspaceItemKind
}
export type PageHeaderContent = {
item?: PageHeaderItem
section?: PageHeaderSection
/** Rendered at the right end of the bar — the page's own buttons. */
actions?: Snippet
}
// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb
// and actions by registering here.
//
// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or
// summary the page edits reaches the bar through the layout's own dependency tracking, with no
// effect keeping two copies in step.
//
// Registrations stack and only the first shows. Pages register on mount, so the first entry is the
// outermost one — an editor nested in a session pane or a drawer is asked to register nothing, and
// a route mounting its successor before unmounting itself cannot flash the wrong path.
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
export const pageHeader = {
get content(): PageHeaderContent | undefined {
return entries[0]?.get()
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++
entries = [...entries, { id, get }]
return id
},
release(id: number) {
entries = entries.filter((e) => e.id !== id)
}
}
@@ -81,6 +81,7 @@
import { twMerge } from 'tailwind-merge'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
import NavHandle from '$lib/components/sidebar/NavHandle.svelte'
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
@@ -1041,6 +1042,25 @@
<!-- Windmill brand mark anchoring the sidebar bottom (the header slot is taken
by the workspace picker). -->
<!-- The page header's first control: one toggle for the sidebar, shown or hidden. -->
{#snippet sidebarToggle()}
{@const hidden = navDetached.val}
<Tooltip class="flex" placement="bottom" small>
<button
class="p-1.5 rounded hover:bg-surface-hover"
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
onclick={() => setDetached(!hidden)}
>
{#if hidden}
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
{:else}
<PanelLeftDashed size={16} class="flex-shrink-0 text-hint" />
{/if}
</button>
{#snippet text()}{hidden ? 'Show sidebar' : 'Hide sidebar'}{/snippet}
</Tooltip>
{/snippet}
{#snippet brandMark(collapsed: boolean)}
<div class="flex items-center gap-x-1.5 text-xs font-semibold text-emphasis">
<WindmillIcon white={darkMode} height="16px" width="16px" />
@@ -1198,31 +1218,10 @@
class="h-full flex flex-col"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<!-- Workspace picker as the drawer header (replaces the Windmill logo). -->
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center gap-1">
<Menubar class="w-full min-w-0">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
{/snippet}
</Menubar>
{#if detachedFloating}
<!-- Same spot as the docked sidebar's detach button, so the two swap in place. -->
<Tooltip class="flex" placement="bottom" small>
<button
class="p-1.5 -mr-1 rounded hover:bg-surface-hover"
aria-label="Attach sidebar"
onclick={() => setDetached(false)}
>
<PanelLeft size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
</button>
{#snippet text()}Attach sidebar{/snippet}
</Tooltip>
{/if}
</div>
<!-- Top row: the workspace ⇄ sessions switch, as in the docked rail. The
workspace picker lives in the page header. -->
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 w-52">
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center">
<SessionModeSwitch
mode={sessionMode ? 'session' : 'nav'}
onToggle={() => (preserveMenuOnNextNav = true)}
@@ -1345,34 +1344,15 @@
)}
onpointerdown={startSidebarResize}
></div>
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
Kept in both modes: it scopes which workspace family's sessions
the sessions sidebar shows. -->
<div class="flex-shrink-0 px-2 h-12 flex items-center gap-1">
<Menubar class="w-full min-w-0">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} {isCollapsed} />
{/snippet}
</Menubar>
{#if !isCollapsed}
<!-- Same spot as the detached card's attach button, so the two swap in place.
The icon-only rail has no room for it and keeps the footer copy. -->
<Tooltip class="flex" placement="bottom" small>
<button
class="p-1.5 -mr-1 rounded hover:bg-surface-hover"
aria-label="Detach sidebar"
onclick={() => setDetached(true)}
>
<PanelLeftDashed size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
</button>
{#snippet text()}Detach sidebar{/snippet}
</Tooltip>
{/if}
</div>
<!-- Top row: the workspace ⇄ sessions switch, level with the page header's own
row so the two read as one band. The workspace picker itself lives in that
header now, which is why nothing names the workspace here. -->
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 {isCollapsed ? 'flex justify-center' : ''}">
<div
class="flex-shrink-0 px-2 h-12 flex items-center {isCollapsed
? 'justify-center'
: ''}"
>
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
</div>
{/if}
@@ -1650,7 +1630,13 @@
transitionClass={sidebarTransitionClass}
isMobile={useDrawer}
onMenuOpen={() => navHandleSlot.open()}
/>
>
{#snippet header()}
{#if !menuHidden && !devOnly}
<PageHeaderBar toggle={sidebarToggle} />
{/if}
{/snippet}
</AiChatLayout>
</div>
</div>
{:else}