feat: one breadcrumb in the page header, and the banners that used to sit under it

The header band now carries a single breadcrumb — workspace, fork, then the item's
path — built from one component, so every segment has the same size, weight and icon
size, and slashes separate them. The item's kind icon rides with its summary, after a
dot, rather than with the path.

The fork and drafts banners move into the band as badges plus a subtle Review button:
ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on
the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu,
alongside the toggle that pins the session chat to the page.

The flow editor and the app viewer hand their bar to the band too, and a route can drop
the band's bottom border when its own content draws one — the border stays in the box
with a transparent colour, so contents never shift a pixel across a navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-22 16:22:24 +02:00
co-authored by Claude Opus 5
parent 7625427009
commit 559ffb7285
30 changed files with 1047 additions and 691 deletions
@@ -0,0 +1,16 @@
<!--
@component
The inside of one breadcrumb part: an optional leading icon and the label.
Every part of the header's breadcrumb renders this, whether its dropdown is a melt menu or a
popover, so they share one look. Separators are the host's job — a part must not contain the
slash before it, or hovering the part would highlight the separator too.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
let { label, icon }: { label: string; icon?: Snippet } = $props()
</script>
{@render icon?.()}
<span class="truncate">{label}</span>
@@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to
close siblings.
-->
<script lang="ts">
import { ChevronRight } from 'lucide-svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import WorkspaceItemDrillPicker, {
type Scope
@@ -15,7 +15,8 @@ close siblings.
interface Props {
label: string
withChevron?: boolean
/** Leading glyph, e.g. the folder or user a path scope names. */
icon?: import('svelte').Snippet
extraClass?: string
disabled?: boolean
/** Where the picker lands when this segment is clicked. */
@@ -33,7 +34,7 @@ close siblings.
let {
label,
withChevron = false,
icon,
extraClass = '',
disabled = false,
initialScope,
@@ -71,8 +72,7 @@ close siblings.
{disabled}
>
{#snippet trigger()}
{#if withChevron}<ChevronRight size={10} class="shrink-0" /><span class="truncate">{label}</span
>{:else}{label}{/if}
<BreadcrumbItemContent {label} {icon} />
{/snippet}
{#snippet content()}
<WorkspaceItemDrillPicker
@@ -1,5 +1,5 @@
<script lang="ts">
import { Pencil } from 'lucide-svelte'
import { ChevronRight, Pencil } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -14,6 +14,7 @@
type WorkspaceItemKind
} from '$lib/components/workspacePicker'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { splitItemPath } from '$lib/components/breadcrumbPath'
import { isOwner } from '$lib/utils'
import { userStore } from '$lib/stores'
import {
@@ -105,27 +106,7 @@
// follows the live `path` — coherent with the picker because we inject a
// virtual current-item entry at that same live path (see `currentItem`).
let displayPath = $derived(snapshotPath ?? path ?? '')
/** Breakdown for breadcrumb rendering. Top-level dirs (scopes) come first
* — `f/<folder>` or `u/<user>` — then any number of subfolders, then the
* item name. Each dir entry's `fullPath` is the cumulative path up to
* that level, used to key the picker's `initialOpen` / `initialHighlight`. */
let segments = $derived.by(() => {
const parts = displayPath.split('/')
if (parts.length < 3) return null
const scope = parts.slice(0, 2).join('/')
const slug = parts.slice(2)
const dirs: { name: string; fullPath: string }[] = []
// Top-level scope is a dir.
dirs.push({ name: scope, fullPath: scope })
// Intermediate slug parts (everything except the last one).
let acc = scope
for (let i = 0; i < slug.length - 1; i++) {
acc = `${acc}/${slug[i]}`
dirs.push({ name: slug[i], fullPath: acc })
}
const leaf = { name: slug[slug.length - 1], fullPath: displayPath }
return { dirs, leaf }
})
let segments = $derived(splitItemPath(displayPath) ?? null)
// Treat an empty path as ownable so the pen popover lets a user pick the
// path for a brand-new item. `Path.reset()` then synthesizes a default
@@ -173,9 +154,9 @@
{#if segments}
{#each segments.dirs as dir, i (dir.fullPath)}
{@const dKey = dirKey('all', dir.fullPath)}
<ChevronRight size={10} class="shrink-0" />
<BreadcrumbSegment
label={dir.name}
withChevron
extraClass={i === 0 ? 'gap-0.5 min-w-0 max-w-[40%]' : 'gap-0.5 min-w-0'}
initialScope={i === 0
? { kind: 'all' }
@@ -188,9 +169,9 @@
{/each}
{@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)}
{@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath}
<ChevronRight size={10} class="shrink-0" />
<BreadcrumbSegment
label={segments.leaf.name}
withChevron
extraClass="gap-0.5 min-w-0"
initialScope={leafParent ? { kind: 'all', dir: leafParent } : { kind: 'all' }}
initialHighlight={leafKey}
+78 -2
View File
@@ -37,6 +37,7 @@
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { getContext, onDestroy, setContext, untrack } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -155,12 +156,16 @@
othersDraftsCount = 0,
onOpenOthersDrafts,
onTestJob,
condensedHeader = false
condensedHeader = false,
/** True for the route's own editor: its top bar becomes the page header. */
ownsPageHeader = false
}: FlowBuilderProps = $props()
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
// In the page header the buttons ride a 40px band, the same size the condensed session-pane
// header uses.
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the global store, so
@@ -1266,6 +1271,16 @@
setContext('FlowCopilotContext', flowCopilotContext)
// The header's action buttons render under the page header, not under this component, so the
// contexts they look up have to travel with them. Read back here, after every setContext above.
const headerContexts = new Map<any, any>([
['FlowEditorContext', getContext('FlowEditorContext')],
['TriggerContext', getContext('TriggerContext')],
['FlowGraphAssetContext', getContext('FlowGraphAssetContext')],
['FlowCopilotContext', flowCopilotContext],
['customUi', customUi]
])
let renderCount = $state(0)
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
@@ -1525,6 +1540,28 @@
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
<div bind:this={flowBuilderRoot} class="flex flex-col h-full">
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: its breadcrumb and
summary are the header's, and its buttons are the header's actions. -->
<PageHeaderContent
item={{
kind: 'flow',
path: $pathStore,
savedPath: initialPath,
summary: flowStore.val.summary,
workspaceId: autosaveWorkspace,
onBehalfOfEmail: $savedOnBehalfOfEmail,
pathEditable: customUi?.topBar?.editablePath != false,
summaryEditable: customUi?.topBar?.editableSummary != false,
onPathChange: (v) => ($pathStore = v),
onSummaryChange: (v) => (flowStore.val.summary = v),
onNavigate: (item) => onNavigate?.(item)
}}
actions={flowHeaderActions}
contexts={headerContexts}
barBorder={false}
/>
{:else}
<!-- Nav between steps-->
<div
bind:clientWidth={topbarWidth}
@@ -1597,6 +1634,45 @@
/>
</div>
</div>
{/if}
{#snippet flowHeaderActions()}
{#if $enterpriseLicense && !newFlow && !inSessionPane}
<Awareness />
{/if}
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
{#if diffEnabled && !diffInMenu}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
it never shows on hover. pointer-events-none on the button lets the
hover reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize={headerBtnSize}
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{#if !compactTopbar}
{@render previewButtons()}
{/if}
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
{loadingSave}
unifiedSize={headerBtnSize}
{dropdownItems}
/>
{/snippet}
<!-- Rendered either inline in the top bar (wide) or as a graph overlay
(compactTopbar). Crossing the 720px threshold remounts
FlowPreviewButtons; any open preview state will reset. -->
@@ -1,14 +1,14 @@
<script lang="ts">
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Badge } from './common'
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
import { ScriptService } from '$lib/gen'
import type { WorkspaceComparison } from '$lib/gen'
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
import { Button } from './common'
import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte'
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { childWorkspaceNoun, devLabelWord } from '$lib/utils/devWorkspaceLabel'
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
import { diffActionableInDirection } from '$lib/utils_workspace_deploy'
let loading = $state(false)
@@ -20,12 +20,10 @@
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
// Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev
// workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
let isFork = $derived(parentWorkspaceId != null)
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData))
// Operators run scripts and flows, they never deploy a fork, so the banner and
// its CTA are noise for them. Gates the fetches too, not just the markup: the
@@ -67,14 +65,12 @@
let showDraftsOnly = $derived(upToDate && draftCount > 0)
// Leaving for a workspace with no comparison of its own has to invalidate whatever
// is in flight too, or that response lands as this one's answer — and its CI counts
// would be fetched for paths this workspace may not even have.
// is in flight too, or that response lands as this one's answer.
function dropComparison() {
requestSeq++
comparison = undefined
comparisonFor = undefined
loading = false
resetCiTestSummary()
}
// `isNotOperator` is a dependency of its own: it only turns true once this
@@ -113,8 +109,7 @@
if (comparisonFor !== ws) {
comparison = undefined
comparisonFor = undefined
resetCiTestSummary()
}
}
const seq = ++requestSeq
loading = true
error = undefined
@@ -159,68 +154,6 @@
}
}
let ciTestPassing = $state(0)
let ciTestFailing = $state(0)
let ciTestRunning = $state(0)
let ciTestTotal = $state(0)
// These describe the rows of one comparison, so they are dropped with it.
function resetCiTestSummary() {
ciTestPassing = 0
ciTestFailing = 0
ciTestRunning = 0
ciTestTotal = 0
}
async function fetchCiTestSummary() {
if (!$workspaceStore || !comparison?.diffs) return
const items = comparison.diffs
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
if (items.length === 0) return
// Counted for the comparison current at call time — the poll below outlives a
// fork switch, and a slow batch for the fork we left would repaint this one's.
const seq = requestSeq
try {
const batch = await ScriptService.getCiTestResultsBatch({
workspace: $workspaceStore,
requestBody: { items }
})
if (seq !== requestSeq) return
let passing = 0
let failing = 0
let running = 0
let total = 0
for (const results of Object.values(batch)) {
for (const r of results) {
total++
if (r.status === 'success') passing++
else if (r.status === 'failure' || r.status === 'canceled') failing++
else if (r.status === 'running' || (r.job_id && !r.status)) running++
}
}
ciTestPassing = passing
ciTestFailing = failing
ciTestRunning = running
ciTestTotal = total
} catch (e) {
console.error('Failed to fetch CI test summary:', e)
}
}
$effect(() => {
if (comparison && comparison.summary.total_diffs > 0) {
fetchCiTestSummary()
}
})
// Poll while any CI test is still running
$effect(() => {
if (ciTestRunning <= 0) return
const interval = setInterval(fetchCiTestSummary, 3000)
return () => clearInterval(interval)
})
// Counted with the compare page's own predicate so the banner never advertises a
// direction whose list is empty: the `ahead`/`behind` sums in the summary include
// rows a direction does not carry, and miss a parent-only row that the update
@@ -246,191 +179,60 @@
</script>
{#if showBanner}
<!-- Side padding mirrors the page content container below, so the banner
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<!-- The summary wraps inside its own column while the CTA keeps its width and
stays on the first line: laid out as one non-wrapping row, the summary is
long enough on a laptop-width viewport to push the button out of the
banner instead of getting shorter. -->
<div class="flex items-center justify-between gap-x-3">
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 min-w-0">
<GitFork class="w-4 h-4 text-accent shrink-0" />
<div class="text-sm min-w-0">
<span class="font-medium text-blue-900 dark:text-blue-100">
{isDevWorkspace
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
: 'Fork of'}
<b>{parentWorkspaceData?.name}</b
>{#if parentWorkspaceData?.name !== parentWorkspaceId}
({parentWorkspaceId}){/if}
</span>
</div>
{#if loading}
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
{:else if error}
<span class="text-xs text-red-600 dark:text-red-400">
{error}
</span>
{:else if comparison}
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0">
{#if comparison.summary.total_diffs > 0}
<span class="text-blue-700 dark:text-blue-100">
{forkAheadBehindMessage(changesAhead, changesBehind)}
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
.summary.total_diffs} items<span class="hidden lg:inline">:</span>
</span>
<!-- The per-kind breakdown is the first thing to go on a narrow
viewport: the item total above already sizes the change set, and
the compare page carries the detail. -->
<div
class="hidden lg:flex items-center flex-wrap gap-x-2 gap-y-1 whitespace-nowrap"
>
{#if comparison.summary.scripts_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.scripts_changed} script{comparison.summary
.scripts_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.flows_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.apps_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.resources_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.resources_changed} resource{comparison.summary
.resources_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.variables_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.variables_changed} variable{comparison.summary
.variables_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.resource_types_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.resource_types_changed} resource type{comparison.summary
.resource_types_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.folders_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.folders_changed} folder{comparison.summary
.folders_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.schedules_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.schedules_changed} schedule{comparison.summary
.schedules_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.triggers_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.triggers_changed} trigger{comparison.summary
.triggers_changed !== 1
? 's'
: ''}
</span>
{/if}
</div>
{#if ciTestTotal > 0}
-
{#if ciTestFailing > 0}
<div
class="flex items-center gap-1 text-red-600 dark:text-red-400 whitespace-nowrap"
>
<CircleX class="w-3 h-3" />
<span>CI: {ciTestFailing} failing</span>
</div>
{:else if ciTestRunning > 0}
<div
class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400 whitespace-nowrap"
>
<Loader2 class="w-3 h-3 animate-spin" />
<span>CI: {ciTestRunning} running</span>
</div>
{:else}
<div
class="flex items-center gap-1 text-green-600 dark:text-green-400 whitespace-nowrap"
>
<CircleCheck class="w-3 h-3" />
<span>CI: {ciTestPassing} passing</span>
</div>
{/if}
{/if}
{#if comparison.summary.conflicts > 0}
-
<div
class="flex items-center gap-1 text-orange-600 dark:text-orange-400 whitespace-nowrap"
>
<AlertTriangle class="w-3 h-3" />
<span
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
? 's'
: ''}</span
>
</div>
{/if}
{:else if comparison.skipped_comparison}
<span class="text-blue-600 dark:text-blue-200">
This {currentNoun} was created before the addition of certain windmill features, and
therefore the changes with its parent workspace cannot be displayed.</span
>
{:else if showDraftsOnly}
<span class="text-blue-700 dark:text-blue-100">
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
</span>
{:else}
<span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
variant="default"
unifiedSize="sm"
onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer}
>
{#if showDraftsOnly}
Review & deploy drafts
{:else if !hasAnswer || changesAhead > 0}
Review & Deploy Changes
{:else}
Review & Update {currentNoun}
{/if}
</Button>
</div>
</div>
</div>
</div>
<!-- In the band, not a banner: the breadcrumb already says which fork this is, so all this has
to carry is how far it has drifted and the way to review that. The per-kind breakdown, the
CI line and the conflict count live on the compare page the button opens. -->
<PageHeaderContent actions={forkAction} />
{/if}
{#snippet forkAction()}
{@const total = comparison?.summary.total_diffs ?? 0}
{@const conflicts = comparison?.summary.conflicts ?? 0}
{#if loading}
<span class="text-2xs text-tertiary">Checking for changes…</span>
{:else if error}
<span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span>
{:else if showDraftsOnly}
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
<Button
variant="subtle"
unifiedSize="sm"
onclick={openDraftCompare}
title={`Review and deploy this workspace's drafts`}
>
Review & deploy drafts
</Button>
{:else if total > 0}
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
to give its parent, behind is what it has yet to take. -->
{#if changesAhead > 0}
<Badge color="blue" small title="{changesAhead} ahead of {parentWorkspaceId}">
↑ {changesAhead}
</Badge>
{/if}
{#if changesBehind > 0}
<Badge color="gray" small title="{changesBehind} behind {parentWorkspaceId}">
↓ {changesBehind}
</Badge>
{/if}
{#if conflicts > 0}
<Badge color="orange" small title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''}">
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
onclick={openComparisonDrawer}
title={`${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`}
>
{#if !hasAnswer || changesAhead > 0}
Review & Deploy Changes
{:else}
Review & Update {currentNoun}
{/if}
</Button>
{/if}
{/snippet}
@@ -0,0 +1,306 @@
<!--
@component
The page header's breadcrumb: workspace / fork / kind / path, in one flat line.
Every segment is a trigger for the picker that changes it — the workspace menu, the fork family
picker, and the item drill picker for the kind and each path level — and every segment wears the
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 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 { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { base } from '$lib/base'
import { twMerge } from 'tailwind-merge'
import { Badge } from '$lib/components/common'
import { forkAccentStyle } from '$lib/utils/forkColor'
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 type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
let {
item,
section,
actingWorkspaceId
}: {
item?: PageHeaderItem
section?: PageHeaderSection
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
} = $props()
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
// 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.
// Home is the root the workspace part already stands for, so it names no page of its own.
const routePage = $derived.by(() => {
if (item || section) return undefined
const found = navPageFor(page.url.pathname, base)
return found?.path === '/' ? undefined : found
})
/** One look for every segment, whichever picker it opens. */
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
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))
const forkable = useForkableWorkspaces({
workspaces: () => $userWorkspaces,
currentWorkspaceId: () => scopeId,
isSuperadmin: () => !!$superadmin
})
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 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
)
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
)
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)
}
</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} />
</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}
{#snippet sectionKindIcon()}
{#if section?.kind}<WorkspaceItemKindIcon kind={section.kind} size={ICON} />{/if}
{/snippet}
{#snippet folderIcon()}
<Folder size={ICON} class="flex-shrink-0 text-tertiary" />
{/snippet}
{#snippet userIcon()}
<User size={ICON} class="flex-shrink-0 text-tertiary" />
{/snippet}
{#snippet slash()}
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
{/snippet}
<nav
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}
>
<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
>
{/if}
</span>
{/snippet}
</WorkspaceFamilyPicker>
{/if}
{#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)}
/>
{: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)}
/>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={SEGMENT}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{:else if section}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class="{SEGMENT} hover:bg-transparent hover:text-primary {section.content ? '' : 'truncate'}"
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{:else if routePage}
{@const RouteIcon = routePage.icon}
<span class="{SEGMENT} hover:bg-transparent hover:text-primary" aria-current="page">
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{/if}
</nav>
@@ -0,0 +1,27 @@
<!--
@component
A breadcrumb segment that opens a melt menu.
The menu hands its trigger builder to a snippet, and a builder store cannot be subscribed inside
one — so the segment takes it as a prop here, where both its attributes and its action are legal.
-->
<script lang="ts">
import { conditionalMelt } from '$lib/utils'
import type { Snippet } from 'svelte'
let {
trigger,
class: className = '',
title,
children
}: {
trigger?: any
class?: string
title?: string
children: Snippet
} = $props()
</script>
<button use:conditionalMelt={trigger} {...$trigger} class={className} {title}>
{@render children()}
</button>
@@ -7,72 +7,79 @@ 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 { 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'
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()
let { border = true }: { border?: boolean } = $props()
const content = $derived(pageHeader.content)
const actions = $derived(pageHeader.actions)
// A route can drop the band's bottom edge when its own content draws one. The border stays in
// the box either way — only its colour goes — so the band's contents do not shift a pixel when
// a navigation swaps one kind of route for the other.
const showBorder = $derived(content?.barBorder ?? border)
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} compact strictWorkspaceSelect={false} />
{/snippet}
</Menubar>
{#if item}
<EditorHeader
inline
hideSummary={!!item.summaryContent}
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 ?? '')}
/>
{#if item.summaryContent}
<div class="min-w-0">{@render item.summaryContent()}</div>
{/if}
{: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>
<div
data-page-header
class="flex items-center gap-1 h-10 px-2 shrink-0 min-w-0 bg-surface border-b {showBorder
? ''
: 'border-transparent'}"
>
{#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. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div data-nav-handle onmouseenter={() => navHandleSlot.open()}>
<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>
</div>
{/if}
{#if content?.actions}
<NavBreadcrumb {item} {section} actingWorkspaceId={content?.actingWorkspaceId} />
{#if item && (item.summaryContent || item.summary)}
<!-- 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. -->
<div class="flex items-center gap-1 min-w-0">
<WorkspaceItemKindIcon kind={item.kind} />
{#if item.summaryContent}
{@render item.summaryContent()}
{:else}
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
{/if}
</div>
{/if}
{#if actions.length > 0}
<div class="ml-auto flex items-center gap-2 shrink-0">
{#key content.contexts}
<ContextBridge contexts={content.contexts}>
{@render content.actions()}
</ContextBridge>
{/key}
{#each actions as entry, i (i)}
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
{/each}
</div>
{/if}
</div>
@@ -10,10 +10,28 @@ the header, so nesting is decided by the caller rather than guessed from context
import { onMount } from 'svelte'
import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte'
let { item, section, actions, contexts }: PageHeaderContent = $props()
let {
item,
section,
actions,
contexts,
barPlacement,
actingWorkspaceId,
barRightInset,
barBorder
}: PageHeaderContent = $props()
onMount(() => {
const id = pageHeader.register(() => ({ item, section, actions, contexts }))
const id = pageHeader.register(() => ({
item,
section,
actions,
contexts,
barPlacement,
actingWorkspaceId,
barRightInset,
barBorder
}))
return () => pageHeader.release(id)
})
</script>
@@ -28,8 +28,8 @@
editable?: boolean
onSaved?: (newPath: string) => void
kind?: 'flow' | 'script'
/** Drop the path line from the trigger, for a host that shows the path itself. */
hidePath?: boolean
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
}
let {
@@ -40,7 +40,7 @@
editable = false,
onSaved,
kind = 'flow',
hidePath = false
compact = false
}: Props = $props()
let editSummary = $state('')
@@ -111,9 +111,11 @@
>
{#snippet trigger()}
<div
class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
? 'items-center px-1 py-0.5'
: 'flex-col items-start px-2 py-1'}"
>
{#if !hidePath}
{#if !compact}
<span
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
@@ -121,7 +123,9 @@
{/if}
<div class="flex items-center gap-3 max-w-full">
<span
class="text-sm font-semibold truncate {emptyString(summary)
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
summary
)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
@@ -238,11 +242,13 @@
{/snippet}
</Popover>
{:else}
<div class="min-w-0 truncate flex flex-col px-2">
{#if !emptyString(summary) && !hidePath}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
<span class="text-sm font-semibold text-emphasis truncate">
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
>
{emptyString(summary) ? (path ?? '') : summary}
</span>
</div>
@@ -1,10 +1,10 @@
<script lang="ts">
import { workspaceStore, userWorkspaces } from '$lib/stores'
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { Button } from './common'
import { Pencil } from 'lucide-svelte'
import { Badge, Button } from './common'
import { goto } from '$app/navigation'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
// Surfaces pending drafts (scripts/flows/apps) for the current workspace and
// links to the compare page in draft mode. Mutually exclusive with
@@ -29,23 +29,16 @@
</script>
{#if !isFork && draftCount > 0}
<!-- Side padding mirrors the page content container below, so the banner
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div class="flex items-center gap-3 min-w-0">
<Pencil class="w-4 h-4 text-accent shrink-0" />
<span class="text-xs font-medium text-blue-900 dark:text-blue-100">
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
</span>
</div>
<!-- Same button as the sibling ForkWorkspaceBanner CTA (they sit on the
same home page), kept visually identical on purpose. -->
<Button variant="default" unifiedSize="sm" onclick={openDraftCompare}>
Review & deploy drafts
</Button>
</div>
</div>
</div>
<!-- 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} />
{/if}
{#snippet draftsAction()}
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}>
Review & deploy drafts
</Button>
{/snippet}
@@ -0,0 +1,19 @@
<!--
@component
The glyph the rest of the app gives a workspace item's kind (see RowIcon), at the caller's size.
-->
<script lang="ts">
import { Code2, LayoutDashboard } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
let { kind, size = 14 }: { kind: WorkspaceItemKind; size?: number } = $props()
</script>
{#if kind === 'flow'}
<BarsStaggered {size} class="flex-shrink-0 text-teal-500" />
{:else if kind === 'app'}
<LayoutDashboard {size} class="flex-shrink-0 text-orange-500" />
{:else}
<Code2 {size} class="flex-shrink-0 text-blue-500" />
{/if}
@@ -12,6 +12,7 @@
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
@@ -147,7 +148,20 @@
</PublicAppFrame>
{#if canWriteApp && !hideEditBtn}
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
</div>
<!-- The page header carries it, with the app's name: a floating button over the app's own
canvas lands on whatever the app draws there. -->
<PageHeaderContent
item={{ kind: 'app', path, raw_app: true, summaryEditable: false, pathEditable: false }}
actions={editAction}
/>
{/if}
{#snippet editAction()}
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href={editHref}
id="app-edit-btn">Edit</Button
>
{/snippet}
@@ -0,0 +1,23 @@
/** A directory level of an item's path, with the full path down to it. */
export type PathDir = { name: string; fullPath: string }
/**
* Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/<folder>` or
* `u/<user>`), then each subfolder, then the item itself. Each level carries the cumulative path,
* which is what the picker opens and highlights.
*
* Returns undefined for anything shorter than scope + name, such as a path still being typed.
*/
export function splitItemPath(path: string): { dirs: PathDir[]; leaf: PathDir } | undefined {
const parts = path.split('/')
if (parts.length < 3) return undefined
const scope = parts.slice(0, 2).join('/')
const slug = parts.slice(2)
const dirs: PathDir[] = [{ name: scope, fullPath: scope }]
let acc = scope
for (let i = 0; i < slug.length - 1; i++) {
acc = `${acc}/${slug[i]}`
dirs.push({ name: slug[i], fullPath: acc })
}
return { dirs, leaf: { name: slug[slug.length - 1], fullPath: path } }
}
@@ -144,7 +144,7 @@
{inheritedLabels}
{onSaved}
kind={errorHandlerKind}
hidePath
compact
/>
{/snippet}
@@ -159,7 +159,7 @@
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="default"
unifiedSize="xs"
unifiedSize="sm"
on:click={async () => {
dispatch('seeTriggers')
await tick()
@@ -172,17 +172,23 @@
{@render trigger_badges?.()}
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
{/key}
{/if}
{#if wide.current}
<ErrorHandlerToggleButton kind={errorHandlerKind} {scriptOrFlowPath} bind:errorHandlerMuted />
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
bind:errorHandlerMuted
unifiedSize="sm"
/>
{/if}
{#each barButtons as btn (btn.label)}
{@const dropdownItems = dropdownItemsOf(btn)}
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
unifiedSize="sm"
{dropdownItems}
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
btnClasses="flex items-center gap-1 whitespace-nowrap"
@@ -10,9 +10,16 @@
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
iconOnly?: boolean
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
}
let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props()
let {
kind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
iconOnly = true,
unifiedSize = 'md'
}: Props = $props()
async function toggleErrorHandler(): Promise<void> {
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
@@ -24,7 +31,7 @@
title={errorHandlerMuted === undefined || !errorHandlerMuted
? 'Disable workspace error handler for this script'
: 'Enable workspace error handler for this script'}
unifiedSize="md"
{unifiedSize}
on:click={toggleErrorHandler}
variant="subtle"
startIcon={{
@@ -75,4 +75,7 @@ export type FlowBuilderProps = {
// EditorHeader's path/breadcrumb row dropped (summary only). Used by the
// session preview to save vertical room.
condensedHeader?: boolean
/** The editor's path/summary and actions go to the route's page header instead of a bar of
* its own. Only the full-page editor sets it; embedders keep their own bar. */
ownsPageHeader?: boolean
}
@@ -20,15 +20,7 @@
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import {
ArrowUp,
ExternalLink,
Globe2,
KeyRound,
PlugZap,
Settings,
WandSparkles
} from 'lucide-svelte'
import { ArrowUp, Globe2, KeyRound, Pin, PinOff, PlugZap, Settings } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { Badge } from '../common'
import CloseButton from '../common/CloseButton.svelte'
@@ -46,6 +38,8 @@
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import { USER_SETTINGS_HASH } from '../sidebar/settings'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { onboardingProfile } from '$lib/onboardingProfile'
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
@@ -293,49 +287,54 @@
</Button>
{/each}
</div>
{:else if showComposer}
<!-- All that is left of the composer once dismissed: sits with the CLI/MCP row so the
collapsed home page is one quiet line. -->
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: WandSparkles }}
onClick={() => setCollapsed(false)}
>
Build with AI
</Button>
{:else}
<div></div>
{/if}
<!-- Not AI-related, so shown even when the composer is hidden. -->
<div class="flex flex-row items-center gap-1">
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: PlugZap }}
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{#if !$userStore?.operator && HOME_SHOW_HUB}
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: Globe2 }}
endIcon={{ icon: ExternalLink }}
href={$hubBaseUrlStore}
target="_blank"
>
Hub
</Button>
{/if}
</div>
</div>
</div>
</div>
<HomeConnectDrawer bind:this={homeConnectDrawer} />
<!-- 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} />
{#snippet homeMenu()}
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
this page — all of them preferences or side trips, none of them the page's work. -->
<DropdownV2
placement="bottom-end"
size="sm"
items={[
...(showComposer
? [
collapsed
? {
displayName: 'Pin session chat to the page',
icon: Pin,
action: () => setCollapsed(false)
}
: {
displayName: 'Unpin session chat',
icon: PinOff,
action: () => setCollapsed(true)
}
]
: []),
{
displayName: 'CLI / MCP',
icon: PlugZap,
action: () => homeConnectDrawer?.openDrawer?.()
},
...(!$userStore?.operator && HOME_SHOW_HUB
? [
{
displayName: 'Hub',
icon: Globe2,
href: $hubBaseUrlStore,
hrefTarget: '_blank' as const
}
]
: [])
]}
/>
{/snippet}
@@ -25,6 +25,8 @@ export type PageHeaderItem = {
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
export type PageHeaderSection = {
label: string
/** Rendered in place of `label` when the name is itself a control, such as an editable title. */
content?: Snippet
/** Scopes the segment's picker, when the section maps to a kind of item. */
kind?: WorkspaceItemKind
}
@@ -34,6 +36,19 @@ export type PageHeaderContent = {
section?: PageHeaderSection
/** Rendered at the right end of the bar — the page's own buttons. */
actions?: Snippet
/** The workspace this page acts on, when it is not the one the app is pointed at — a session
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
* when it really is a fork. */
actingWorkspaceId?: string
/** False drops the band's bottom edge, for a page that draws its own first line. */
barBorder?: boolean
/** Width in px, at the right of the viewport, that this page owns from the top down — a
* session's side panel. The band stops there instead of running over it, and the band floats
* above the page rather than pushing it down, so that column starts at the top. */
barRightInset?: number
/** 'inline' when the route renders the bar itself — for a layout whose side panel must run to
* the top of the viewport beside it, rather than starting under a full-width bar. */
barPlacement?: 'layout' | 'inline'
/** Contexts those buttons look up. They render under the header, not under the page that
* wrote them, so anything the page's tree provides has to travel with them. */
contexts?: Map<any, any>
@@ -46,15 +61,42 @@ export type PageHeaderContent = {
// 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.
// Registrations stack, and what the bar shows is their merge (see `content`). 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.
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
export const pageHeader = {
/**
* The registrations merged, later ones winning field by field: a route sets the frame (where
* the bar goes) and something nested in it — the active session, say — fills the breadcrumb
* and the actions.
*/
get content(): PageHeaderContent | undefined {
return entries[0]?.get()
if (entries.length === 0) return undefined
const merged: PageHeaderContent = {}
for (const e of entries) {
const c = e.get()
if (c.item !== undefined) merged.item = c.item
if (c.section !== undefined) merged.section = c.section
if (c.contexts !== undefined) merged.contexts = c.contexts
if (c.barPlacement !== undefined) merged.barPlacement = c.barPlacement
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.barBorder !== undefined) merged.barBorder = c.barBorder
}
return merged
},
/**
* Every registration's actions, in registration order: a page and something inside it can both
* 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 }] : []
})
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
@@ -285,6 +285,6 @@
src={iframeSrc}
sandbox={sandboxAttr}
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
class="w-full h-full min-h-screen bg-white border-none"
class="w-full h-full bg-white border-none"
></iframe>
{/if}
@@ -69,7 +69,7 @@
iconOnly={isCollapsed}
tooltip={isCollapsed ? 'Workspace' : undefined}
size="sm"
class="w-full justify-center"
class="w-full justify-center whitespace-nowrap"
onActivate={onNavActivate}
/>
<ToggleButton
@@ -80,7 +80,7 @@
iconOnly={isCollapsed}
tooltip={isCollapsed ? 'AI Sessions (beta)' : undefined}
size="sm"
class="w-full justify-center"
class="w-full justify-center whitespace-nowrap"
/>
{/snippet}
</ToggleButtonGroup>
@@ -4,7 +4,8 @@
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import SessionsBetaBanner from './SessionsBetaBanner.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import { Button, NameIdTooltip } from '$lib/components/common'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
@@ -18,17 +19,12 @@
import {
Archive,
ArchiveRestore,
ArrowUpRight,
EllipsisVertical,
ExternalLink,
MessageSquareDashed,
Pencil,
Settings,
Trash2,
X
} from 'lucide-svelte'
import { type Item } from '$lib/utils'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import SessionChangesBar from './SessionChangesBar.svelte'
import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte'
@@ -53,17 +49,15 @@
} from './sessionState.svelte'
import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { base } from '$app/paths'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
// headerInset: extra left padding on the chat header so it clears a floating
// control (the detached sidebar's handle) sitting at the screen's top-left.
let {
sessionId,
headerInset = false
headerInPage = false
}: {
sessionId: string
headerInset?: boolean
/** True for the session the page is showing: its header rides the page header bar. */
headerInPage?: boolean
} = $props()
// Parent keys by sessionId; this wrapper only mounts when the session exists.
@@ -112,34 +106,9 @@
})
})
// The workspace the session acts on, shown in the header "Acting on" strip via the shared
// WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets.
const acting = $derived.by(() => {
const wsId = session ? getEffectiveWorkspaceId(session) : undefined
if (!wsId) return undefined
const name = $userWorkspaces.find((w) => w.id === wsId)?.name ?? wsId
return { targetId: wsId, name }
})
// Ellipsis menu on the "Acting on" chip. Both entries are real links (so
// modifier/middle clicks open a new tab); the `workspace` query param
// points the navigation at the acting workspace — the layout applies it on
// both full loads and client-side query changes. The trailing external-link
// glyphs make the leave-the-session navigation explicit.
const actingMenu = $derived<Item[]>([
{
displayName: 'Workspace settings',
icon: Settings,
href: `${base}/workspace_settings?workspace=${acting?.targetId ?? ''}`,
extra: externalLinkHint
},
{
displayName: 'Go to this workspace',
icon: ArrowUpRight,
href: `${base}/?workspace=${acting?.targetId ?? ''}`,
extra: externalLinkHint
}
])
// 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)
// Load copilot config (models, providers) for the workspace the session acts
// on, not the navigation workspace — a session deliberately leaves
@@ -150,7 +119,7 @@
// otherwise race to clobber the active chat's model config.
$effect(() => {
if (sessionState.currentSessionId !== sessionId) return
const ws = acting?.targetId ?? $workspaceStore
const ws = actingWorkspaceId ?? $workspaceStore
if (ws) {
loadCopilot(ws)
}
@@ -334,10 +303,6 @@
'flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md'
</script>
{#snippet externalLinkHint()}
<ExternalLink size={12} class="shrink-0 text-tertiary" />
{/snippet}
{#if !session || !runtime}
<div class="p-8 text-secondary text-sm">Session not found</div>
{:else}
@@ -411,95 +376,79 @@
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
{#snippet sessionHeader()}
<EditableInput
bind:this={summaryInput}
value={session.summary ?? ''}
placeholder="Untitled session"
onSave={(v) => renameSession(session.id, v)}
class="text-xs font-normal"
inputClass="!text-xs !font-normal"
/>
<DropdownV2
fixedHeight={false}
placement="bottom-start"
enableFlyTransition
items={[
{
displayName: 'Rename',
icon: Pencil,
action: () => summaryInput?.edit()
},
...(session.archived
? // No Unarchive when the workspace is gone — it can't persist
// (putSession guard) and reconcile would re-archive it.
isUnavailable
? []
: [
{
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
]
: [
{
displayName: 'Archive',
icon: Archive,
action: () => archiveAndReset()
}
]),
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (deleteConfirmOpen = true)
}
]}
>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="More"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
{/snippet}
{#if headerInPage}
<!-- The chat's own header row is the page header on this route: the session's name is its
breadcrumb, and the menu is its action. -->
<PageHeaderContent
section={{ label: session.summary || session.name, content: sessionHeader }}
{actingWorkspaceId}
/>
{/if}
<!-- The wrapper contributes only the chat column; edited items are shown in the
page's preview tabs (PreviewTabHost) beside it, not a second pane here. The
single Pane fills 100% (no explicit `size`, so Splitpanes auto-distributes). -->
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
<header
class="flex flex-row items-center gap-1 {headerInset
? 'pl-11'
: 'pl-4'} pr-4 py-2 shrink-0"
>
<EditableInput
bind:this={summaryInput}
value={session.summary ?? ''}
placeholder="Untitled session"
onSave={(v) => renameSession(session.id, v)}
class="text-sm font-semibold"
inputClass="!text-sm !font-semibold"
/>
<DropdownV2
fixedHeight={false}
placement="bottom-start"
enableFlyTransition
items={[
{
displayName: 'Rename',
icon: Pencil,
action: () => summaryInput?.edit()
},
...(session.archived
? // No Unarchive when the workspace is gone — it can't persist
// (putSession guard) and reconcile would re-archive it.
isUnavailable
? []
: [
{
displayName: 'Unarchive',
icon: ArchiveRestore,
action: () => setSessionArchived(session.id, false)
}
]
: [
{
displayName: 'Archive',
icon: Archive,
action: () => archiveAndReset()
}
]),
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (deleteConfirmOpen = true)
}
]}
>
{#snippet buttonReplacement()}
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
title="More"
>
<EllipsisVertical size={14} />
</span>
{/snippet}
</DropdownV2>
{#if acting && hasFirstUserMessage}
<!-- "Acting on" context: workspace root (avatar + name) and, when the
session runs in a fork, the fork name (accent pill) — with a button
to jump into that workspace. Compact; sits right after the title.
Hidden until the session has started — a new (un-sent) session shows
the "Run in" picker (SessionWorkspaceBar) instead. -->
<div class="flex items-center gap-1 min-w-0 text-2xs text-tertiary">
<span class="shrink-0">Acting on</span>
<!-- Hover reveals the workspace name + id + copy button (shared
NameIdTooltip, same as the sidebar family picker), so the chip
carries the copy affordance without an inline button. -->
<NameIdTooltip name={acting.name} id={acting.targetId}>
<WorkspaceScopeTrigger
workspaceId={acting.targetId}
showChevron={false}
interactive={false}
disableTitle
class="max-w-[16rem]"
menuItems={actingMenu}
/>
</NameIdTooltip>
</div>
{/if}
</header>
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<AIChat
bind:this={aiChat}
@@ -97,7 +97,7 @@
<Popover
appearTimeout={0}
disappearTimeout={0}
class="w-full"
class={compact ? 'w-auto' : 'w-full'}
disablePopup={!isCollapsed}
placement="right"
>
@@ -122,7 +122,8 @@
{href}
data-light-mode={lightMode}
class={twMerge(
'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full',
'group flex items-center px-2 py-2 font-light rounded-md gap-2',
compact ? 'w-auto' : 'w-full',
sidebarClasses.hoverBg,
'transition-all relative',
sublabel ? 'h-10' : 'h-8',
@@ -173,7 +174,7 @@
{/if}
{/if}
<div class="flex flex-col text-left grow min-w-0">
<div class="flex flex-col text-left min-w-0 {compact ? '' : 'grow'}">
{#if !isCollapsed && label}
<div
class={twMerge(
@@ -12,31 +12,25 @@
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import { NAV_PAGES } from './navPages'
import {
BookOpen,
ServerCog,
Boxes,
Calendar,
DollarSign,
Eye,
Logs,
FolderCog,
FolderOpen,
Github,
HelpCircle,
Home,
LogOut,
Newspaper,
Play,
Route,
Settings,
UserCog,
Users,
Plus,
Unplug,
AlertCircle,
Database,
Pyramid,
MailIcon,
ChevronDown,
ChevronRight
@@ -197,21 +191,16 @@
const itemClass = twMerge(
'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
let mainMenuLinks = $derived(
[
{
label: 'Home',
href: `${base}/`,
icon: Home,
// Labels, paths and icons come from the shared page table, so the nav and the page header's
// breadcrumb always call a page the same thing. Everything else here is nav-only.
const MAIN_MENU_EXTRAS: Record<string, { aiId: string; aiDescription: string; onclick?: () => void }> =
{
Home: {
aiId: 'sidebar-menu-link-home',
aiDescription:
"Button to navigate to home which contains all the user's scripts, flows and apps"
},
{
label: 'Runs',
href: `${base}/runs`,
icon: Play,
disabled: !allows('runs'),
Runs: {
aiId: 'sidebar-menu-link-runs',
aiDescription: 'Button to navigate to runs',
onclick: () => {
@@ -220,47 +209,28 @@
}, 100)
}
},
{
label: 'Variables',
href: `${base}/variables`,
icon: DollarSign,
disabled: !allows('variables'),
Variables: {
aiId: 'sidebar-menu-link-variables',
aiDescription: 'Button to navigate to variables'
},
{
label: 'Resources',
href: `${base}/resources`,
icon: Boxes,
disabled: !allows('resources'),
Resources: {
aiId: 'sidebar-menu-link-resources',
aiDescription: 'Button to navigate to resources'
},
{
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
disabled: !allows('assets'),
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
{
label: 'Folders',
href: `${base}/folders`,
icon: FolderOpen,
disabled: !allows('folders'),
aiId: 'sidebar-menu-link-folders',
aiDescription: 'Button to navigate to folders'
},
{
label: 'Groups',
href: `${base}/groups`,
icon: Users,
disabled: !allows('groups'),
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups'
}
].filter((l) => !excludeMainLabels.includes(l.label))
Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' },
Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' },
Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' }
}
let mainMenuLinks = $derived(
NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS)
.map((p) => ({
label: p.label,
href: p.path === '/' ? `${base}/` : `${base}${p.path}`,
icon: p.icon,
disabled: p.operatorKey ? !allows(p.operatorKey) : false,
...MAIN_MENU_EXTRAS[p.label]
}))
.filter((l) => !excludeMainLabels.includes(l.label))
)
let defaultExtraTriggerLinks = $derived([
{
@@ -49,7 +49,7 @@
name={currentFamily?.name ?? $workspaceStore ?? ''}
id={currentFamily?.id ?? $workspaceStore ?? ''}
disablePopup={isCollapsed}
class="block w-full"
class={compact ? 'block' : 'block w-full'}
>
<MenuButton
icon={Building}
@@ -19,7 +19,10 @@
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
// `compact` is the page header's variant: sized to its label on one line, rather than filling
// a rail's width.
let { isCollapsed = false, compact = false }: { isCollapsed?: boolean; compact?: boolean } =
$props()
const effectiveId = $derived($workspaceStore ?? undefined)
// Resolve once here and pass to the picker + trigger below, avoiding a duplicate lookup (see
@@ -62,7 +65,11 @@
}
</script>
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
<div
class="flex items-center min-w-0 {compact ? '' : 'px-2'} {isCollapsed
? 'justify-center'
: ''} py-0.5 rounded-md"
>
<WorkspaceFamilyPicker
selectedId={effectiveId}
{forkableWorkspaces}
@@ -70,7 +77,7 @@
onRequestCreateFork={openForkModal}
{settingsHref}
{settingsLabel}
class="min-w-0 w-full"
class={compact ? 'min-w-0' : 'min-w-0 w-full'}
>
{#snippet trigger()}
<WorkspaceScopeTrigger
@@ -78,8 +85,8 @@
{forkableWorkspaces}
{isCollapsed}
{rootLabel}
wrap
class="w-full"
wrap={!compact}
class={compact ? '' : 'w-full'}
/>
{/snippet}
</WorkspaceFamilyPicker>
@@ -0,0 +1,61 @@
import {
Boxes,
Calendar,
DollarSign,
Eye,
FolderOpen,
Home,
HardHat,
Play,
Pyramid,
Unplug,
Users
} from 'lucide-svelte'
import type { OperatorPageKey } from './operatorRoutes'
/**
* The workspace pages the sidebar links to, and the names the page header's breadcrumb gives them.
* One table so a page cannot be called one thing in the nav and another in the header.
*/
export type NavPage = {
label: string
/** Path under `base`, matched against the current route by longest prefix. */
path: string
icon: any
/** The `operator_settings` key that admits an operator, when the page has one. */
operatorKey?: OperatorPageKey
}
export const NAV_PAGES: NavPage[] = [
{ label: 'Home', path: '/', icon: Home },
{ label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' },
{ label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' },
{ label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' },
{ label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' },
{ label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' },
{ label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' },
{ label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' },
{ label: 'Triggers', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
{ label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' },
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' }
]
/**
* The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route
* the table does not name falls back to its first segment (`/service_logs` → "Service logs").
*/
export function navPageFor(pathname: string, base: string): NavPage | undefined {
const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname
const match = NAV_PAGES.filter(
(p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/'))
).sort((a, b) => b.path.length - a.path.length)[0]
if (match) return match
const first = path.split('/').filter(Boolean)[0]
if (!first) return undefined
const label = first.replace(/_/g, ' ')
return {
label: label.charAt(0).toUpperCase() + label.slice(1),
path: `/${first}`,
icon: undefined
}
}
@@ -82,6 +82,7 @@
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
@@ -108,7 +109,6 @@
import { sessionState } from '$lib/components/sessions/sessionState.svelte'
import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte'
import { currentWorkspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
import WorkspaceScopeHeader from '$lib/components/sidebar/WorkspaceScopeHeader.svelte'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
@@ -1011,13 +1011,9 @@
{#snippet sidebarToggle()}
{@const hidden = navDetached.val}
<Tooltip class="flex" placement="bottom" small>
<!-- Hidden, hovering slides the card in for a look; the click is what puts the sidebar
back for good. -->
<button
data-nav-handle
class="p-1.5 rounded hover:bg-surface-hover"
class="p-1.5 rounded hover:bg-surface-hover flex-shrink-0"
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
onmouseenter={() => hidden && navHandleSlot.open()}
onclick={() => setDetached(!hidden)}
>
{#if hidden}
@@ -1184,13 +1180,6 @@
</div>
{/if}
{#if !sessionMode}
<!-- Workspace scope (fork picker), under the switch. -->
<div class="pb-1 w-52 {sessionsSwitchShown ? '' : 'pt-2'}">
<WorkspaceScopeHeader isCollapsed={false} />
</div>
{/if}
{#if sessionMode}
<!-- Session mode: the session list owns the rail.
w-52 cap (matches the desktop sidebar width): the drawer is
@@ -1262,8 +1251,15 @@
<SidebarUsage isCollapsed={false} />
</div>
<!-- Same footer slot the docked rail puts it in, so the control that hides the
sidebar and the one that brings it back sit in the same place. -->
<div class="px-4 pt-3 pb-3.5 w-52 flex items-center justify-between">
{@render brandMark(false)}
{#if detachedFloating}
<div class="flex gap-3">
{@render sidebarToggle()}
</div>
{/if}
</div>
</div>
</div>
@@ -1274,7 +1270,8 @@
id="sidebar"
class={classNames(
dockToggled ? '' : 'wm-sidebar-in',
'flex flex-col fixed top-12 bottom-0 z-40 ',
// The rail owns the full height of the left edge; the band starts to its right.
'flex flex-col fixed inset-y-0 z-40 ',
sidebarTransitionClass,
devOnly ? '!hidden' : ''
)}
@@ -1304,18 +1301,13 @@
header, which is why nothing names the workspace here. -->
{#if !embedded && sessionsSwitchShown}
<div
class="flex-shrink-0 px-2 h-12 flex items-center {isCollapsed
? 'justify-center'
: ''}"
class="flex-shrink-0 px-2 gap-1 flex {isCollapsed
? 'flex-col items-center py-2'
: 'h-12 items-center'}"
>
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
</div>
{/if}
{#if !sessionMode}
<!-- Workspace scope (fork picker), under the switch. -->
<div class="pb-1 {sessionsSwitchShown ? '' : 'pt-2'}">
<WorkspaceScopeHeader {isCollapsed} />
<div class="min-w-0 flex-1">
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
</div>
</div>
{/if}
@@ -1401,19 +1393,7 @@
>
{@render brandMark(isCollapsed)}
<div class="flex {isCollapsed ? 'flex-col gap-3' : 'gap-3'}">
{#if isCollapsed}
<!-- p-2/-m-2 widens the hit area to ~32px without moving the icon. -->
<Tooltip class="flex" placement="top" small>
<button
class="p-2 -m-2 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}
{@render sidebarToggle()}
{#if !$userStore?.operator}
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
@@ -1532,10 +1512,30 @@
</div>
</div>
{/if}
<div class="flex flex-col h-full w-full">
{#if !menuHidden && !devOnly}
<!-- The band runs the full width, above the sidebar: the sidebar starts under it. -->
<PageHeaderBar toggle={sidebarToggle} />
<div class="flex flex-col h-full w-full relative">
{#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}
{/if}
{#if $enterpriseLicense && !menuHidden}
<!-- Announcements are an EE feature, so the component never mounts on CE: no
@@ -552,6 +552,7 @@
</div>
{:else if renderEditor}
<FlowBuilder
ownsPageHeader
onTakeLatest={draftBaseVersion
? async (shown?: string) => {
// The version the drawer showed as head; see /scripts/edit.
@@ -14,6 +14,7 @@
Loader2
} from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -55,7 +56,6 @@
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
import { previewTargetForSessionTarget } from '$lib/components/sessions/sessionPreviewTabs.svelte'
import { base } from '$lib/base'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import {
artifactKey,
itemDisplayName,
@@ -406,6 +406,8 @@
let emptyStateNewTabOpen = $state(false)
let fullscreen = $state(false)
// Width of the preview panel, so the band can stop where it starts.
let previewWidth = $state(0)
// Fullscreen is page state, not per-session, so it outlives a session switch —
// tell the incoming session's model, whose own collapsed flag it overrides, or
// re-opening the item plainly on screen would be judged invisible and not flash.
@@ -840,6 +842,30 @@
}}
/>
<!-- The session showing fills the band's breadcrumb. The band is the layout's, but it stops
where the preview panel starts so that panel can run from the top of the viewport. -->
<PageHeaderContent
barRightInset={previewCollapsed || fullscreen ? undefined : previewWidth}
barBorder={false}
actions={sessionPageActions}
/>
{#snippet sessionPageActions()}
{#if previewCollapsed && !fullscreen}
<!-- Collapsed preview: the way back to the side panel, in the band with the session's own
controls rather than floating over the chat. -->
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: PanelRightOpen }}
title="Open side panel"
onclick={() => owner?.setCollapsed(false)}
>
Open side panel
</Button>
{/if}
{/snippet}
<div class="h-full flex flex-col min-h-0">
{#if embedded}
<!-- Rendered inside a preview iframe — opening the sessions UI here would
@@ -900,7 +926,14 @@
{#if !fullscreen}
<!-- Chat column. Warm sessions stay mounted (stacked, visibility-toggled)
so switching between them preserves chat scroll/draft state. -->
<Pane bind:size={chatPaneSize} minSize={25} class="flex flex-col min-h-0">
<!-- The band floats over this column's top strip while a panel is beside it, so the
column starts below it. The offset belongs on the pane, not on the stack's
container: the session stacks are `absolute inset-0` and would ignore padding. -->
<Pane
bind:size={chatPaneSize}
minSize={25}
class="flex flex-col min-h-0 {previewCollapsed || fullscreen ? '' : 'pt-10'}"
>
<div class="relative flex-1 min-h-0">
{#each warmSessions as s (s.id)}
<div
@@ -909,7 +942,7 @@
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} headerInset={navDetached.val} />
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
</div>
{/each}
</div>
@@ -925,7 +958,11 @@
maxSize={previewCollapsed ? 0 : 100}
class="flex flex-col min-h-0"
>
<div class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}">
<div
bind:clientWidth={previewWidth}
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}"
>
<div
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
? ''
@@ -1154,21 +1191,6 @@
</div>
</Pane>
</Splitpanes>
{#if previewCollapsed && !fullscreen}
<!-- Collapsed preview: no rail — a floating launcher in the top-right to
reopen the side panel. -->
<div class="absolute top-2 right-3 z-50">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: PanelRightOpen }}
title="Open side panel"
onclick={() => owner?.setCollapsed(false)}
>
Open side panel
</Button>
</div>
{/if}
</div>
{#if aiHiddenVerdict === undefined}
<div class="absolute inset-0 z-20 flex items-center justify-center bg-surface">