feat: one band, one workspace part, and the pickers that hang off it

The band is a single element whose placement is styled rather than branched: a page
registering a side-panel inset used to swap it between two `{#if}` arms, destroying the
header and rebuilding it a frame later — a blink of no header with the content sliding
up into the gap.

The workspace and its fork become one breadcrumb part: the name leads home (inert in a
session, where the workspace is the session's scope rather than a place to go) and one
chevron opens the picker, which already expands a family to reach its forks. The
picker's panel hangs from the part's left edge, opens with the active family expanded,
and closes before the switch runs — the switch renames the workspace, which moves the
trigger the panel hangs from, and floating-ui was chasing it across the viewport.

The item's path is a plain label rather than a row of pickers: same separators and
weight, and a click copies it with its own confirmation. The kind icon goes, the page
below says what kind it is.

Around them: a "prod" badge for a root workspace, the session's acting fork beside the
session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in
the header, the drafts count as a sentence, and a fade when the workspace changes under
a page that stays mounted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 09:07:46 +02:00
co-authored by Claude Opus 5
parent 9129cfe39f
commit 283cf48ccf
16 changed files with 390 additions and 288 deletions
+163 -168
View File
@@ -7,30 +7,15 @@ picker, and the item drill picker for the kind and each path level — and every
same weight, size and icon size, so the line reads as one control rather than four.
-->
<script lang="ts">
import { Building, Folder, GitFork, User } from 'lucide-svelte'
import { Building, ChevronDown, Folder, GitFork, User } from 'lucide-svelte'
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
import { Menu, Menubar } from '$lib/components/meltComponents'
import NavBreadcrumbTrigger from './NavBreadcrumbTrigger.svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import {
userWorkspaces,
workspaceStore,
workspaceColor,
globalForkModal,
userStore,
superadmin
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { fixupUrlAfterWorkspaceSwitch } from './sidebar/workspaceSwitchUrl'
import { workspaceAIClients } from '$lib/components/copilot/lib'
import {
findWorkspaceRoot,
findWorkspaceDescendants,
isForkOwner
} from '$lib/utils/workspaceHierarchy'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { base } from '$lib/base'
import { twMerge } from 'tailwind-merge'
@@ -39,15 +24,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { page } from '$app/state'
import { navPageFor } from './sidebar/navPages'
import { getContrastTextColor } from '$lib/utils'
import { splitItemPath } from './breadcrumbPath'
import {
dirKey,
KIND_LABEL_LOWER,
kindKey,
leafKeyFor,
type WorkspaceItem
} from '$lib/components/workspacePicker'
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
@@ -66,6 +44,12 @@ same weight, size and icon size, so the line reads as one control rather than fo
} = $props()
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
// In session mode the workspace is the session's scope, not a place to navigate to: the name
// says which workspace the chat acts on, and going home from it would leave the session.
const sessionMode = $derived(page.url.pathname.startsWith(`${base}/sessions`))
const homeHref = $derived(
actingWorkspaceId ? `${base}/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `${base}/`
)
// With no item or section registered, the route still says where the user is: Runs, Variables,
// and the rest name themselves in the breadcrumb rather than leaving it at the workspace.
@@ -80,9 +64,25 @@ same weight, size and icon size, so the line reads as one control rather than fo
const SEGMENT =
'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors'
const ICON = 14
/** The workspace disc: the same glyph as the rest, with a ring of its own around it. */
const DISC = 20
/** Width of the name beside the chevron, so the picker can hang from the part's own left edge. */
let nameWidth = $state(0)
let workspaceMenu: Menu | undefined = $state(undefined)
// Its own confirmation under the path rather than the global toast: the answer belongs where
// the click was, and a toast for something this small is a lot of furniture.
let pathCopied = $state(false)
let copiedReset: ReturnType<typeof setTimeout> | undefined
async function copyPath() {
if (!item?.path || !(await copyToClipboard(item.path, false))) return
pathCopied = true
clearTimeout(copiedReset)
copiedReset = setTimeout(() => (pathCopied = false), 1500)
}
const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? []))
const workspaceName = $derived(family?.name ?? scopeId ?? '')
const discColor = $derived(family?.color ?? $workspaceColor ?? 'rgb(var(--color-surface-sunken))')
const glyphColor = $derived(getContrastTextColor(discColor))
@@ -93,73 +93,48 @@ same weight, size and icon size, so the line reads as one control rather than fo
})
const forkableWorkspaces = $derived(forkable.current)
const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId))
const forkCount = $derived.by(() => {
const root = findWorkspaceRoot(scopeId, forkableWorkspaces)
return root ? findWorkspaceDescendants(root.id, forkableWorkspaces).length : 0
})
// A fork names itself; a root says how many it has, which is the hint that this opens them.
const forkLabel = $derived(
currentWs && currentWs.id !== family?.id
? currentWs.name
: `${forkCount} fork${forkCount === 1 ? '' : 's'}`
)
// Forks are a developer's workspace. An operator sees the segment only when one of them is
// theirs — they were added to a fork, or are standing in one — and never the row that makes one.
const inFork = $derived(!!currentWs?.parent_workspace_id)
// A page acting on another workspace (a session) names that fork and nothing else: "0 forks"
// would be about the workspace the user is browsing, not the one the session runs in.
const showFork = $derived(
actingWorkspaceId ? inFork : !$userStore?.operator || forkCount > 0 || inFork
)
// A session names the workspace it acts on beside its own title, so here the family name alone
// says where the user is browsing — the fork in this spot would read as a workspace switch.
const showFork = $derived(inFork && !actingWorkspaceId)
const familyName = $derived(family?.name ?? scopeId ?? '')
// The workspace the user actually stands in, which is the fork's own name inside a fork.
const scopeName = $derived(currentWs?.name ?? familyName)
// A fork wears its workspace colour and, for a dev workspace, its environment badge — the same
// two marks the fork chip carries everywhere else.
const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined)
const devBadge = $derived(
inFork && currentWs?.is_dev_workspace ? devBadgeText(currentWs.dev_workspace_label) : undefined
)
// A dev workspace wears its environment; the root wears "prod", so which environment the user
// is standing in is answered the same way whichever one it is.
const envBadge = $derived.by(() => {
if (actingWorkspaceId) return undefined
if (!inFork) return { text: 'prod', dev: false }
if (currentWs?.is_dev_workspace)
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
return undefined
})
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
)
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
// fork's own name inside, so the family's name is not painted as if it were the fork.
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
const segments = $derived(item?.path ? splitItemPath(item.path) : undefined)
const currentItem = $derived<(WorkspaceItem & { savedPath?: string }) | undefined>(
item
? {
path: item.path ?? '',
summary: item.summary ?? '',
kind: item.kind,
raw_app: item.raw_app,
savedPath: item.savedPath
}
: undefined
)
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the segment's icon. */
const scopeKind = $derived(segments?.dirs[0]?.fullPath.split('/')[0])
function switchWorkspaceDirect(id: string) {
if ($workspaceStore === id) return
workspaceAIClients.init(id)
switchWorkspace(id)
void fixupUrlAfterWorkspaceSwitch(id)
}
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
</script>
{#snippet workspaceDisc()}
<svg width={ICON} height={ICON} viewBox="0 0 16 16" class="flex-shrink-0">
<circle cx="8" cy="8" r="8" fill={discColor} />
<foreignObject x="3" y="3" width="10" height="10">
<Building size={10} style={glyphColor ? `color: ${glyphColor}` : undefined} />
<!-- The glyph is the same size as every other icon on the line; only the disc around it is
larger, by the ring it needs to read as a disc at all. -->
<svg width={DISC} height={DISC} viewBox="0 0 {DISC} {DISC}" class="flex-shrink-0">
<circle cx={DISC / 2} cy={DISC / 2} r={DISC / 2} fill={discColor} />
<foreignObject x={(DISC - ICON) / 2} y={(DISC - ICON) / 2} width={ICON} height={ICON}>
<Building size={ICON} style={glyphColor ? `color: ${glyphColor}` : undefined} />
</foreignObject>
</svg>
{/snippet}
{#snippet forkIcon()}
<GitFork size={ICON} class="flex-shrink-0 text-tertiary" />
{/snippet}
{#snippet itemKindIcon()}
{#if item}<WorkspaceItemKindIcon kind={item.kind} size={ICON} />{/if}
{/snippet}
@@ -176,6 +151,18 @@ same weight, size and icon size, so the line reads as one control rather than fo
<User size={ICON} class="flex-shrink-0 text-tertiary" />
{/snippet}
{#snippet envBadgeMark()}
{#if envBadge}
<Badge
color={envBadge.dev ? 'dark-blue' : 'gray'}
small
class={envBadge.dev
? 'text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white'
: 'text-3xs px-1 py-0'}>{envBadge.text}</Badge
>
{/if}
{/snippet}
{#snippet slash()}
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
{/snippet}
@@ -184,103 +171,111 @@ same weight, size and icon size, so the line reads as one control rather than fo
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
{#snippet triggr({ trigger })}
<NavBreadcrumbTrigger {trigger} class={SEGMENT} title={workspaceName}>
<BreadcrumbItemContent label={workspaceName} icon={workspaceDisc} />
</NavBreadcrumbTrigger>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody item={menuItem} />
{/snippet}
</Menu>
{/snippet}
</Menubar>
{#if showFork}
{@render slash()}
<WorkspaceFamilyPicker
selectedId={scopeId}
{forkableWorkspaces}
onPick={switchWorkspaceDirect}
allowCreateFork={!$userStore?.operator}
onRequestCreateFork={() => (globalForkModal.val = { opened: true })}
settingsHref={canManageWorkspace ? `${base}/workspace_settings` : undefined}
settingsLabel={`${currentWs?.name ?? scopeId ?? 'Workspace'} settings`}
class="min-w-0"
>
{#snippet trigger()}
<!-- twMerge so the accent's text colour wins over the segment's own. -->
<span
class={twMerge(
SEGMENT,
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)}
style={forkAccent}
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<div class="flex items-center min-w-0">
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
the name to line its left edge up with the disc. -->
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
<svelte:element
this={sessionMode ? 'span' : 'a'}
href={sessionMode ? undefined : homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it is
part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span
class={twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)}
style={forkAccent}
>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
</svelte:element>
</div>
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
contentStyle="margin-left: {-nameWidth}px"
>
<BreadcrumbItemContent label={forkLabel} icon={forkIcon} />
{#if devBadge}
<Badge
color="dark-blue"
small
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
>{devBadge}</Badge
{#snippet triggr({ trigger })}
<NavBreadcrumbTrigger
{trigger}
class="flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
{/if}
</span>
<ChevronDown size={ICON} class="flex-shrink-0" />
</NavBreadcrumbTrigger>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody item={menuItem} closeMenu={() => workspaceMenu?.close()} />
{/snippet}
</Menu>
{/snippet}
</WorkspaceFamilyPicker>
{/if}
</Menubar>
</div>
{#if item || section || routePage}
{@render slash()}
{/if}
{#if item}
{#if segments}
{#each segments.dirs as dir, i (dir.fullPath)}
{#if i > 0}{@render slash()}{/if}
<BreadcrumbSegment
label={i === 0 ? dir.name.split('/').slice(1).join('/') : dir.name}
icon={i === 0 ? (scopeKind === 'u' ? userIcon : folderIcon) : undefined}
extraClass="{SEGMENT} {i === 0 ? 'max-w-[40%]' : ''}"
initialScope={i === 0
? { kind: 'all' }
: { kind: 'all', dir: segments.dirs[i - 1].fullPath }}
initialHighlight={dirKey('all', dir.fullPath)}
{currentItem}
workspaceId={item.workspaceId}
onPick={(picked) => item?.onNavigate?.(picked)}
/>
{/each}
{@render slash()}
<BreadcrumbSegment
label={segments.leaf.name}
extraClass={SEGMENT}
initialScope={segments.dirs.length
? { kind: 'all', dir: segments.dirs[segments.dirs.length - 1].fullPath }
: { kind: 'all' }}
initialHighlight={leafKeyFor(item.kind, segments.leaf.fullPath)}
isCurrent
{currentItem}
workspaceId={item.workspaceId}
onPick={(picked) => item?.onNavigate?.(picked)}
/>
{#if item.path}
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
to know which item this is, and what they reach for to paste somewhere else. -->
<div class="relative flex items-center min-w-0">
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
pointer and the popup after the click are affordance enough. -->
<button
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
>
{#if scopeKind === 'u'}
{@render userIcon()}
{:else}
{@render folderIcon()}
{/if}
{#each pathLevels as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<span class="truncate">{level}</span>
{/each}
</button>
{#if pathCopied}
<span
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
role="status"
>
Path copied successfully
</span>
{/if}
</div>
{:else}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[item.kind]}
icon={itemKindIcon}
extraClass={SEGMENT}
initialHighlight={kindKey(item.kind)}
isCurrent
{currentItem}
workspaceId={item.workspaceId}
onPick={(picked) => item?.onNavigate?.(picked)}
/>
<span class="{SEGMENT} hover:bg-transparent" aria-current="page">
{@render itemKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[item.kind]}</span>
</span>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
@@ -14,7 +14,8 @@
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Badge, Button } from './common'
import { Button, ButtonType } from './common'
import { twMerge } from 'tailwind-merge'
import Popover from './meltComponents/Popover.svelte'
import Toggle from './Toggle.svelte'
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
@@ -53,28 +54,35 @@
? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}`
: 'Edits restricted'
)
// The Popover renders its trigger as a <button>, so this wears the design system's subtle
// button rather than containing one — a <Button> here would nest a button inside a button.
const triggerClass = twMerge(
'inline-flex items-center gap-1 rounded-md whitespace-nowrap transition-all',
ButtonType.VariantStyles.subtle,
ButtonType.UnifiedSizingClasses.sm,
ButtonType.UnifiedHeightClasses.sm,
ButtonType.UnifiedFontSizes.sm,
'text-xs'
)
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
class="inline-flex items-center"
class={triggerClass}
triggerAttrs={{ 'aria-label': badgeLabel }}
>
{#snippet trigger()}
<!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the
one Popover wraps its trigger in. -->
<Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer">
{#if bypassActive}
<ShieldOff class="h-3 w-3" />
{:else if canonicalDev}
<GitFork class="h-3 w-3" />
{:else}
<Lock class="h-3 w-3" />
{/if}
{badgeLabel}
</Badge>
{#if bypassActive}
<ShieldOff size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{:else if canonicalDev}
<GitFork size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{:else}
<Lock size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{/if}
{badgeLabel}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 p-4 text-xs max-w-sm">
@@ -8,11 +8,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
-->
<script lang="ts">
import { PanelLeft } from 'lucide-svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
@@ -23,7 +21,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const section = $derived(content?.section)
</script>
<div data-page-header class="flex items-center gap-1 h-11 px-2 shrink-0 min-w-0 bg-surface">
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
{#if navDetached.val}
<!-- The only way back to a hidden sidebar: hovering slides the card in for a look, a click
puts it back for good. Docked, the sidebar speaks for itself and nothing leads the bar. -->
@@ -33,16 +31,15 @@ The row's height matches the sidebar's own header row, so the two read as one ba
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<Tooltip class="flex" placement="bottom" small>
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Show sidebar"
onclick={() => (navDetached.val = false)}
>
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
</button>
{#snippet text()}Show sidebar{/snippet}
</Tooltip>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Show sidebar"
onclick={() => (navDetached.val = false)}
>
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
</button>
</div>
{/if}
@@ -62,9 +59,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
not another level of it. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
<!-- The item's kind belongs with the name a human reads, not with its path. -->
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 min-w-0">
<WorkspaceItemKindIcon kind={item.kind} />
{#if item.summaryContent}
{@render item.summaryContent()}
{:else}
@@ -15,6 +15,7 @@ the header, so nesting is decided by the caller rather than guessed from context
section,
hint,
actions,
actionsOrder,
contexts,
barPlacement,
actingWorkspaceId,
@@ -27,6 +28,7 @@ the header, so nesting is decided by the caller rather than guessed from context
section,
hint,
actions,
actionsOrder,
contexts,
barPlacement,
actingWorkspaceId,
+1 -1
View File
@@ -872,7 +872,7 @@
<PageHeaderContent hint={runsHint} actions={headerActions} />
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-4 border rounded-md">
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
<div class="relative z-10 mb-2 flex gap-2">
<Tabs bind:selected={graph}>
<Tab value="RunChart" label="Duration" id="runs-chart-duration-tab" />
@@ -31,10 +31,12 @@
{#if !isFork && draftCount > 0}
<!-- In the band rather than a banner over the page: it is a standing fact about the workspace,
not an interruption, and one button says it and acts on it. -->
<PageHeaderContent actions={draftsAction} />
<PageHeaderContent actions={draftsAction} actionsOrder={-100} />
{/if}
{#snippet draftsAction()}
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it. -->
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
@@ -125,27 +125,33 @@
})
</script>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
<!-- The band draws no edge of its own, so the separation from it is this page's first line. It
goes on the app's wrapper rather than on the frame, which the header-less public page shares —
and as an inset shadow rather than a border, which would take a pixel off an app sized to fill
the page exactly and leave it scrolling. -->
<div class="h-full shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
</div>
{#if canWriteApp && !hideEditBtn}
<!-- The page header carries it, with the app's name: a floating button over the app's own
@@ -296,7 +296,9 @@
<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper,
the hub, and the way back to the composer once it has been put away. -->
<PageHeaderContent actions={homeMenu} />
<!-- Last in the bar whatever else the page registers: a menu of side trips belongs after the
buttons that act on what is on screen. -->
<PageHeaderContent actions={homeMenu} actionsOrder={100} />
{#snippet homeMenu()}
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
@@ -16,6 +16,14 @@
interface Props {
placement?: Placement
/** Drops the fly-out, so the panel is out of the DOM the moment it closes. For a menu whose
* act tears its own trigger down — picking a workspace — where floating-ui spends the
* outro re-placing the panel against a reference on its way out, and it bounces. */
closeInstantly?: boolean
/** Inline style on the panel. A margin here shifts it after floating-ui has placed it,
* which is the only way to anchor it to a box wider than the trigger: melt's menubar
* menu ignores `positioning.offset`. */
contentStyle?: string
justifyEnd?: boolean
lightMode?: boolean
maxHeight?: number
@@ -45,6 +53,8 @@
let {
placement = 'right-start',
contentStyle = '',
closeInstantly = false,
justifyEnd = false,
lightMode = false,
maxHeight = 900,
@@ -220,7 +230,8 @@
data-menu
onmouseenter={cancelPendingClose}
onmouseleave={scheduleClose}
transition:placementFly={{ duration: 100, placement }}
transition:placementFly={{ duration: closeInstantly ? 0 : 100, placement }}
style={contentStyle}
class={twMerge(
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
// Default: scroll on the melt element. submenuSafe moves it to the inner
@@ -36,6 +36,9 @@ export type PageHeaderContent = {
section?: PageHeaderSection
/** Rendered at the right end of the bar — the page's own buttons. */
actions?: Snippet
/** Orders these buttons among the other registrations', ascending; registration order breaks a
* tie. For a menu that must stay at the far end however many pages register before it. */
actionsOrder?: number
/** Rendered right after the page's name in the breadcrumb, for what reads as part of that name
* rather than as an action — a page's documentation tooltip. */
hint?: Snippet
@@ -94,10 +97,14 @@ export const pageHeader = {
* put buttons in the band, and each renders with the contexts its own registration named.
*/
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
return entries.flatMap((e) => {
const c = e.get()
return c.actions ? [{ render: c.actions, contexts: c.contexts }] : []
})
return entries
.flatMap((e, i) => {
const c = e.get()
return c.actions
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
: []
})
.sort((a, b) => a.order - b.order || a.i - b.i)
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
@@ -170,6 +170,9 @@ export function unsandboxedRawAppHtml(
<head>
<meta charset="UTF-8" />
<title>App</title>
<!-- Before the app's own stylesheet, so a bundle that wants a margin can still set one: the
browser default of 8px would otherwise frame every app with a gap it never asked for. -->
<style>html, body { margin: 0; }</style>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx).replace(/</g, '\\u003c') : 'undefined'};
@@ -5,6 +5,7 @@
import SessionsBetaBanner from './SessionsBetaBanner.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -110,6 +111,9 @@
// The workspace the session acts on. The page header's breadcrumb scopes itself to it, so a
// session running in a fork reads as that fork rather than as the navigation workspace.
const actingWorkspaceId = $derived(session ? getEffectiveWorkspaceId(session) : undefined)
const actingIsFork = $derived(
!!actingWorkspaceId && workspaceIsFork(actingWorkspaceId, $userWorkspaces)
)
// Load copilot config (models, providers) for the workspace the session acts
// on, not the navigation workspace — a session deliberately leaves
@@ -433,6 +437,20 @@
</span>
{/snippet}
</DropdownV2>
{#if actingIsFork}
<!-- Only a fork earns this: on the root the breadcrumb already names the workspace the
chat writes to, and repeating it says nothing. The fork is the case worth calling
out, and it belongs with the session's name rather than in the breadcrumb, where it
would read as if the user had switched workspace. -->
<span class="shrink-0 text-2xs text-tertiary pl-1">Acting on</span>
<WorkspaceScopeTrigger
workspaceId={actingWorkspaceId}
showChevron={false}
interactive={false}
class="min-w-0"
/>
{/if}
{/snippet}
{#if headerInPage}
@@ -26,6 +26,8 @@
compact = false
}: Props = $props()
let menu: Menu | undefined = $state(undefined)
// The active workspace's family root — shown in the trigger so a forked active workspace still
// surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly
// like the scope picker right below, so the two never name different heads for one workspace:
@@ -37,7 +39,7 @@
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
</script>
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
<Menu bind:this={menu} {createMenu} usePointerDownOutside placement="bottom-start">
{#snippet triggr({ trigger })}
<!-- Family header reflects the family (root) color, not the active
workspace's — switching into a fork must not recolor it. -->
@@ -73,6 +75,11 @@
{#snippet children({ item })}
<!-- The only strict caller is a standalone page (svix webhook creation) that owns its
own navigation, so there a switch must leave the page where it is. -->
<WorkspacePickerBody {item} {strictWorkspaceSelect} keepPageOnSwitch={strictWorkspaceSelect} />
<WorkspacePickerBody
{item}
{strictWorkspaceSelect}
keepPageOnSwitch={strictWorkspaceSelect}
closeMenu={() => menu?.close()}
/>
{/snippet}
</Menu>
@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import { workspaceMenuHref } from './workspaceMenuHref'
import { isOperatorInWorkspace } from './operatorRoutes'
@@ -48,13 +48,16 @@
item: MenubarMenuElements['item']
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
strictWorkspaceSelect?: boolean
/** Closes the menu this list lives in, so a workspace switch does not run under an open
* panel. The host owns the menu, so only it can. */
closeMenu?: () => void
// Set by a picker embedded in a page that drives its own navigation, so switching
// workspaces must not navigate away from it. Separate from strictWorkspaceSelect,
// which only strips the sidebar-specific rows.
keepPageOnSwitch?: boolean
}
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false }: Props = $props()
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false, closeMenu }: Props = $props()
async function toggleSwitchWorkspace(id: string) {
if ($workspaceStore === id) {
@@ -106,8 +109,13 @@
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
return
}
// This click is handled here rather than by melt, so melt never closes the menu for it and
// the panel would sit through the switch — which renames the workspace in the header,
// moving the very trigger the panel hangs from, with floating-ui chasing it across the
// viewport. Close first, let that reach the DOM, then switch.
e.preventDefault()
toggleSwitchWorkspace(workspace.id)
closeMenu?.()
void tick().then(() => toggleSwitchWorkspace(workspace.id))
}
// Family-first picker: list the workspace families (roots) with their forks
@@ -139,16 +147,41 @@
const canForkHere = $derived(
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
)
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
// stops at that same visibility boundary, so it lands on the same row.
const lineageRoot = $derived.by(() => {
const id = $workspaceStore ?? undefined
if (!id) return undefined
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
})
const familyWorkspaces = $derived.by(() => {
if (strictWorkspaceSelect) return hierarchy
let rootId: string | undefined
return hierarchy.filter((h) => {
if (h.depth === 0) {
rootId = h.workspace.id
return true
}
return !!rootId && expandedFamilies.has(rootId)
})
const visible = strictWorkspaceSelect
? hierarchy
: hierarchy.filter((h) => {
if (h.depth === 0) {
rootId = h.workspace.id
return true
}
return !!rootId && expandedFamilies.has(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.
const activeRoot = lineageRoot?.id
if (!activeRoot) return visible
const families: { root: string | undefined; rows: typeof visible }[] = []
for (const h of visible) {
if (h.depth === 0 || families.length === 0) families.push({ root: h.workspace.id, rows: [h] })
else families[families.length - 1].rows.push(h)
}
const at = families.findIndex((f) => f.root === activeRoot)
if (at <= 0) return visible
const [active] = families.splice(at, 1)
return [active, ...families].flatMap((f) => f.rows)
})
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
@@ -172,27 +205,13 @@
e.stopPropagation()
}
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
// stops at that same visibility boundary, so it lands on the same row.
const lineageRoot = $derived.by(() => {
const id = $workspaceStore ?? undefined
if (!id) return undefined
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
})
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
// Seeded once per instance: opening while a fork is active expands that fork's family,
// putting the tick on the active fork's own row instead of on its collapsed root. The
// host menu renders this only while open, so in practice each open gets a fresh
// instance and therefore a fresh expansion state — except across a close and re-open
// inside the menu's 100ms outro, where Svelte resumes the same instance and whatever
// was expanded by hand survives.
// Seeded once per instance: the family the user is standing in opens expanded, so its forks —
// the ones they are most likely to switch between — are there without a click, and the tick
// sits on the actual workspace rather than on its collapsed root. Other families stay closed.
untrack(() => {
if (lineageRoot && lineageRoot.id !== $workspaceStore) {
if (lineageRoot && familiesWithForks.has(lineageRoot.id)) {
expandedFamilies.add(lineageRoot.id)
}
})
@@ -344,11 +363,7 @@
</MenuItem>
{/if}
{#if canForkHere}
<MenuItem
class={itemClass}
onClick={() => (globalForkModal.val = { opened: true })}
{item}
>
<MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}>
<Plus size={16} />
Workspace fork
</MenuItem>
@@ -534,6 +534,12 @@
// Docking and detaching move and resize the rail itself between its docked box and the
// detached card's (top 44px, bottom and left insets, rounded corners); detaching also slides
// it off the left edge, where the closed card lives. No opacity: the rail never fades.
// The footer's icon buttons wear the rail's own row box — 32px square, 16px glyph — so the
// pair that hides and collapses the sidebar lines up with the links above them, collapsed or
// not. Padding alone drifted: two different paddings, two icon sizes and a negative margin.
const SIDEBAR_ICON_BUTTON =
'h-8 w-8 flex items-center justify-center rounded hover:bg-surface-hover flex-shrink-0'
const RAIL_MORPH_MS = 200
function railMorph(_node: HTMLElement, { offscreen }: { offscreen: boolean }) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -1025,7 +1031,7 @@
{@const hidden = navDetached.val}
<Tooltip class="flex" placement="bottom" small>
<button
class="p-1.5 rounded hover:bg-surface-hover flex-shrink-0"
class={SIDEBAR_ICON_BUTTON}
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
onclick={() => setDetached(!hidden)}
>
@@ -1415,7 +1421,7 @@
{@render sidebarToggle()}
{#if !$userStore?.operator}
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
class={SIDEBAR_ICON_BUTTON}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onclick={() => {
collapsedState = !collapsedState
@@ -1424,9 +1430,9 @@
}}
>
{#if isCollapsed}
<PanelLeftOpen size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
<PanelLeftOpen size={16} class="flex-shrink-0 text-hint" />
{:else}
<PanelLeftClose size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
<PanelLeftClose size={16} class="flex-shrink-0 text-hint" />
{/if}
</button>
{/if}
@@ -1535,26 +1541,24 @@
{#if !menuHidden && !devOnly && pageHeader.content?.barPlacement !== 'inline'}
{@const rightInset = pageHeader.content?.barRightInset}
{@const leftInset = useDrawer ? 0 : railWidth}
{#if rightInset != null}
<!-- A page owning the right edge from the top (a session's side panel) gets the band
as an overlay that stops where that column starts, so the column can reach the
top. The page pads its own content to clear it. -->
<div
class={classNames('absolute top-0 z-30', sidebarTransitionClass)}
style:left="{leftInset}rem"
style:right="{rightInset}px"
>
<PageHeaderBar />
</div>
{:else}
<!-- The band spans the content, beside the rail rather than above it. -->
<div
class={classNames('shrink-0', sidebarTransitionClass)}
style:padding-left="{leftInset}rem"
>
<PageHeaderBar />
</div>
{/if}
<!-- One element for both placements, styled rather than branched: a page registers its
inset on mount, and swapping between two `{#if}` arms would destroy the band and
build another one a frame later — a blink of no header, with the content sliding
up into the gap and the workspace menu losing the trigger it hangs from.
In flow, the band spans the content beside the rail. With an inset, a page owns
the right edge from the top (a session's side panel): the band floats, stopping
where that column starts, and the page pads its own content to clear it. -->
<div
class={classNames(
rightInset != null ? 'absolute top-0 z-30' : 'shrink-0',
sidebarTransitionClass
)}
style:left={rightInset != null ? `${leftInset}rem` : undefined}
style:right={rightInset != null ? `${rightInset}px` : undefined}
style:padding-left={rightInset != null ? undefined : `${leftInset}rem`}
>
<PageHeaderBar />
</div>
{/if}
{#if $enterpriseLicense && !menuHidden}
<!-- Announcements are an EE feature, so the component never mounts on CE: no
@@ -19,11 +19,13 @@
import { writable } from 'svelte/store'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { setQuery } from '$lib/navigation'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
@@ -92,6 +94,22 @@
let showCreateButtons = $state(false)
// A workspace switch keeps this page mounted and swaps its contents under the reader, which
// the mount animation below never covers. Replay it by hand instead of keying the page on the
// workspace: a remount would drop the list's own state and refetch what it is already loading.
let pageEl: HTMLDivElement | undefined = $state(undefined)
const reducedMotion = useReducedMotion()
let animatedWorkspace = $workspaceStore
$effect(() => {
const ws = $workspaceStore
if (ws === animatedWorkspace) return
animatedWorkspace = ws
untrack(() => {
if (!pageEl || reducedMotion.val) return
pageEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' })
})
})
let operatorTour: OperatorTour | undefined = $state(undefined)
// Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
@@ -262,10 +280,19 @@
</DrawerContent>
</Drawer>
{#snippet editRuleBadge()}
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{/snippet}
<div
bind:this={pageEl}
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
style="scrollbar-gutter: stable both-edges;"
>
<!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the
header with the workspace's own name rather than above the list. -->
<PageHeaderContent actions={editRuleBadge} />
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
@@ -285,8 +312,6 @@
<div class="my-4"></div>
{/if}
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if tab == 'hub'}
<div class="flex flex-col gap-y-16">
<div class="flex flex-col pb-8">