feat: shrink the page header in stages and fold page actions by measured width (#11732)

* feat: shrink the page header in stages and let the bar fold page actions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: fold the runs header through the bar and wrap its filter to a second row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: truncate the run id and fold run actions into one menu on a narrow bar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep an item's summary and drop its path on a narrow page header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show the full summary and path read-only when they cannot be edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep secondary detail actions in menus and the star beside the summary

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show secondary detail actions as buttons while the bar has room for them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: fold embedded detail headers, refresh stale fold widths, keep the path beside a summary placeholder

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the duplicate path on read-only detail pages and re-measure folded actions when they change

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: re-measure the runs header when its hidden counters change width

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* revert: drop the runs header's counter version, the badges do not change its width

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: stop the header re-measuring its actions every frame while idle

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-13 23:13:22 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c7ee201655
commit 3857ce3693
17 changed files with 1350 additions and 437 deletions
+10 -13
View File
@@ -40,7 +40,7 @@
import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -230,19 +230,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full group — the
// dropdown, Diff, the preview trio and Deploy — is ~610px.
const compactBelow = $derived(ownsPageHeader ? 1200 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
const diffEnabled = $derived(customUi?.topBar?.diff != false)
// Nothing to compare against until a deployed version exists.
@@ -1588,6 +1583,7 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'flow',
path: pathSnapshot ?? $pathStore,
summary: flowStore.val.summary,
summaryContent: flowSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -1597,6 +1593,7 @@
: undefined
}}
actions={flowHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
+261 -117
View File
@@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line.
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
import { Menu, Menubar } from '$lib/components/meltComponents'
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
@@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line.
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
import {
BAR_STAGES,
FLOOR,
shownPathLevels,
type Part,
type TrailMeasures,
type TrailStage
} from './pageHeaderStages'
let {
item,
section,
afterName,
actingWorkspaceId,
narrow = false,
nameOnly = false
nameOnly = false,
stage = BAR_STAGES[0],
caps = {},
scopesGive = false,
measures = $bindable()
}: {
item?: PageHeaderItem
section?: PageHeaderSection
@@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line.
afterName?: Snippet
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
/** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the
* line before anything else has given way. The names truncate at every width either way. */
narrow?: boolean
/** Which parts the bar has dropped for room (see pageHeaderStages). */
stage?: TrailStage
/** Max widths the bar holds the names to, once dropping whole parts is not enough. */
caps?: Partial<Record<Part, number>>
/** The folders above the name truncate too: even the name at its last floor does not fit. */
scopesGive?: boolean
/** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */
measures?: TrailMeasures
/** Only the page's own name, with no workspace part and no picker — the band inside a
* session's preview frame, where the workspace is the host's and leading the reader out of
* it is exactly what that band must not offer. The page still has to say what it is. */
@@ -145,6 +160,9 @@ not this component) — so the lead can run the whole line.
// The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the
// last segment only for an item that has no summary to follow it.
const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary)
/** Only the summary's text names the item: a control offering to add one does not, and the path
* then stays at every stage. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
// 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.
@@ -156,15 +174,8 @@ not this component) — so the lead can run the whole line.
twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD)
)
// Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that
// one would push a page's buttons off the end of the bar.
const pathSegment = $derived(
twMerge(
SEGMENT,
'gap-1.5 overflow-hidden',
itemIsLast ? LAST : LEAD,
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)
twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD)
)
// Copying is the one thing a segment does that leaves no mark on the page, so it is the one
// without a hover fill: the pointer and the confirmation under it are the affordance. A segment
@@ -191,6 +202,18 @@ not this component) — so the lead can run the whole line.
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))
// The off-screen copies stand in a fixed-width box for what a page renders into the trail: a
// second render of a page's snippet would be a second live copy of its controls.
let afterNameWidth = $state(0)
let sectionContentWidth = $state(0)
/** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */
function capStyle(part: Part, measure: boolean): string | undefined {
if (measure) return `max-width: ${FLOOR[part]}px`
const cap = caps[part]
return cap === undefined ? undefined : `max-width: ${cap}px`
}
</script>
{#snippet workspaceDisc()}
@@ -241,89 +264,129 @@ not this component) — so the lead can run the whole line.
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} 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"
>
{#snippet workspaceMenu_(measure: boolean)}
{#if measure}
<span class="flex items-center h-9 px-1.5 shrink-0">
<ChevronDown size={ICON} class="flex-shrink-0" />
</span>
{:else}
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{/if}
{/snippet}
{#snippet trail(f: TrailStage, measure: boolean)}
{@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName}
{#if !nameOnly}
<!-- 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. -->
<!-- `shrink-[999]`: the whole trail can give width back, and this part gives it first. Flex
takes it from the parts in proportion to what they ask for, so a factor this far above
the rest means the names here are eaten down to their icons before the page's own name
loses a character. -->
<div class="flex items-center min-w-0 shrink-[999]">
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. Its parts never shrink on their own: the bar drops them whole and caps the
names left, in the order pageHeaderStages sets. -->
<div class="flex items-center shrink-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. -->
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}>
<a
href={homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
the name to line its left edge up with the disc. -->
<div
class="flex items-center"
bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)}
>
<svelte:element
this={measure ? 'span' : 'a'}
href={measure ? undefined : homeHref}
class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass}
title={scopeTitle}
>
<!-- The family's name goes before the fork's: the fork is where the work happens, the
family is which product it is part of, and of the two that is the one a reader can
do without. Both truncate to nothing rather than disappearing at a breakpoint —
what is left then is what the part cannot be read without, the disc in the
workspace's own colour, the fork mark and the environment. The hover title
carries the names whole, and the picker beside it names them all. -->
<span class="flex items-center gap-1 min-w-0 shrink-[999]">
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
{#if f.wsButton}
<!-- Down to its disc, the part still leads home: the fork it stands in rides on the
disc as a mark, and the environment stays beside it. -->
<span class="relative flex shrink-0">
{@render workspaceDisc()}
{#if showFork}
<span
class={twMerge(
'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface',
forkAccent
? 'bg-[color:var(--fork-accent-badge)] text-white'
: 'bg-surface-sunken text-primary'
)}
style={forkAccent}
>
<GitFork size={9} />
</span>
{/if}
</span>
{:else}
{@render envBadgeMark()}
{:else}
<!-- The family's name goes before the fork's: the fork is where the work happens,
the family is which product it is part of, and of the two that is the one a
reader can do without. Outside a fork it is the workspace's own name and stays
until the part is down to its disc. -->
<span class="flex items-center gap-1 shrink-0">
{@render workspaceDisc()}
{#if f.familyName || !showFork}
<span class="truncate" style={capStyle('family', measure)}>{familyName}</span>
{/if}
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{#if f.forkName}
<span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span>
{/if}
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{/if}
</a>
</svelte:element>
</div>
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{@render workspaceMenu_(measure)}
</div>
{/if}
{#if !nameOnly && (item || section || routePage)}
<!-- The summary that follows is the item's name for a person, so on a short bar it is the path
that goes (see `TrailStage.path`). What the page hangs after the name stays. -->
{@const pathShown = f.path || !summaryNamesItem}
{#if !nameOnly && (item ? pathShown : section || routePage)}
{@render slash()}
{/if}
{#if item}
{#if item.path}
{#if !pathShown}
{@render afterNameSlot(measure)}
{:else if item.path}
{@const shown = shownPathLevels(pathLevels, f)}
<!-- 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">
@@ -334,19 +397,28 @@ not this component) — so the lead can run the whole line.
{:else}
{@render folderIcon()}
{/if}
{#each pathLevels as level, i (i)}
{#each shown as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<!-- The scopes above the name absorb the squeeze, the name itself last: a path is
read from its end, and which folder a thing is in is answerable from the
picker while the thing's own name is not. -->
<span
class={i === pathLevels.length - 1
? 'truncate'
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
>
{#if i === shown.length - 1}
<span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span>
{:else if level === '…' && !f.middleLevels}
<span class="shrink-0 text-tertiary">…</span>
{:else}
<!-- The folder or user gives width only once the name is down to a few
characters: it is short and says whose thing this is, while a name long
enough to need truncating still reads from its first words. -->
<span
class={twMerge(
'truncate max-w-[8rem]',
scopesGive && !measure ? 'min-w-0' : 'shrink-0'
)}>{level}</span
>
{/if}
{/each}
{/snippet}
{#if item.pathTrigger}
{#if measure}
<span class={pathSegment}>{@render pathLabel()}</span>
{:else if item.pathTrigger}
<!-- The page hangs its own rename off this segment, anchored here so the editor
opens under the path rather than beside the summary. It wears the segment's
own classes, so a clickable path reads the same as one that only copies. -->
@@ -361,8 +433,8 @@ not this component) — so the lead can run the whole line.
{@render pathLabel()}
</button>
{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
{@render afterNameSlot(measure)}
{#if pathCopied && !measure}
<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"
@@ -386,15 +458,21 @@ not this component) — so the lead can run the whole line.
</span>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{#if measure}
<span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}>
{@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{:else}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{/if}
{:else if section}
{#if section.parent}
<!-- Gives width back with the workspace part, before the section's own name does. -->
@@ -405,30 +483,96 @@ not this component) — so the lead can run the whole line.
</span>
{@render slash()}
{/if}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{#if afterName}{@render afterName()}{/if}
{#if section.content && measure}
<span class="shrink-0" style="width: {sectionContentWidth}px"></span>
{:else}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'shrink-0'
)}
aria-current={measure ? undefined : 'page'}
bind:clientWidth={
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
}
>
<!-- A plain label is the page's name, so it truncates the way an item's name does, in
its turn: a run's id, left to flex, is squeezed to nothing under the buttons. -->
{#if section.content}{@render section.content()}{:else}<span
class="truncate"
style={capStyle('name', measure)}>{section.label}</span
>{/if}
</span>
{/if}
{@render afterNameSlot(measure)}
{:else if routePage}
{@const RouteIcon = routePage.icon}
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
whole, so the workspace and fork names give way first when the bar is full. -->
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
aria-current={measure ? undefined : 'page'}
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{#if afterName}{@render afterName()}{/if}
{@render afterNameSlot(measure)}
{/if}
{/snippet}
{#snippet afterNameSlot(measure: boolean)}
{#if afterName}
{#if measure}
<span class="shrink-0" style="width: {afterNameWidth}px"></span>
{:else}
<span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}>
{@render afterName()}
</span>
{/if}
{/if}
{/snippet}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
{@render trail(stage, false)}
</nav>
{#if measures}
<!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and
each name whole, for it to cap them with. Same snippet, same classes, so the widths are the
ones the trail on screen would take. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
{#each BAR_STAGES as s, i (i)}
<nav
class="flex items-center gap-1.5 w-max text-xs font-normal text-primary"
bind:clientWidth={measures.trail[i]}
>
{@render trail(s, true)}
</nav>
{/each}
<!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar
knows the part is not there. -->
<span
class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span
>
<span
class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span
>
<span
class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast || !item ? LAST : LEAD)}
bind:clientWidth={measures.name}
>{item?.path
? (pathLevels[pathLevels.length - 1] ?? '')
: !item && section && !section.kind && !section.content
? section.label
: ''}</span
>
</div>
{/if}
+275 -122
View File
@@ -16,6 +16,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
import { onDestroy } from 'svelte'
import type { Attachment } from 'svelte/attachments'
import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages'
let {
navHidden = false,
@@ -33,15 +36,99 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
/** A summary control can be a placeholder offering to add one; the text is what names the item. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
// it only stops a long path from claiming a third of a short bar before the squeeze starts.
const NARROW_BAR = 1000
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
/** A phone's bar: a tighter gutter around the same row. */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// What the bar drops, and when the page's actions fold, follows from the widths of what is in
// it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page.
/** `gap-1` between the bar's boxes. */
const GAP = 4
/** Actions that shrink or fill the bar have no width of their own to plan around: the trail
* leaves them this much and they take whatever is left. */
const FLEXIBLE_ACTIONS_RESERVE = 240
let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 })
/** The summary held at its floor, with the dot before it. */
let summaryFloorWidth = $state(0)
let summaryTextWidth = $state(0)
/** A page's own summary control, last seen uncapped. */
let summaryContentWidth = $state(0)
let handleWidth = $state(0)
/** The actions box at each fold level it has been seen at, for this page. */
let actionWidthsByVersion = $state<Record<string, (number | undefined)[]>>({})
let actionsEpoch = $state(0)
const actionsVersion = $derived(content?.actionsVersion ?? '')
const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? [])
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
// The bar's own padding, in px, as the classes on the row below set it.
const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8)
const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16)
const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill)
const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false }
const fit = $derived.by((): BarFit => {
// Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold
// a page's buttons for one frame and pop them back out the next.
if (pageHeader.barWidth === 0) return FULL
return fitBar({
barWidth: pageHeader.barWidth,
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
trail: trailMeasures.trail,
summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
summaryNamesItem,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
name: trailMeasures.name
}
})
})
const stage = $derived(BAR_STAGES[fit.stage])
/** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring
* on each one would feed itself. */
const fold = $derived(fit.fold)
onDestroy(pageHeader.provideActionFold(() => fold))
// Re-attached whenever the fold level, the page or the actions' version changes, so the first
// reading there is taken even when the row comes out exactly as wide as it was before.
// The page's actions can change on screen — a run's Cancel goes once the job completes — and
// the other levels most likely changed with them, so a change here drops their widths: kept, a
// stale wider one would hold the row folded for good. The bar measures them again by trying.
function recordActions(fold: number, width: number) {
const version = actionsVersion
const widths = actionWidthsByVersion[version] ?? []
const seen = widths[fold]
// Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote
// kept the bar measuring every frame.
if (seen === width) return
const next = seen !== undefined ? [] : [...widths]
next[fold] = width
actionWidthsByVersion[version] = next
}
function measureActions(
fold: number,
_epoch: number,
_version: string
): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => recordActions(fold, GAP + node.offsetWidth))
ro.observe(node)
return () => ro.disconnect()
}
}
// Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar
// may find it does not fit and fold it further, and that should not flash.
const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
@@ -69,8 +156,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
scrolledUnder = next
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
// nothing to scroll at all. Its actions' widths go with it.
afterNavigate(({ from, to }) => {
scrolledUnder = false
if (from?.url.pathname !== to?.url.pathname) {
actionWidthsByVersion = {}
actionsEpoch++
}
})
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
@@ -86,145 +179,205 @@ The row's height matches the sidebar's own header row, so the two read as one ba
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
// One row at every width. A wrapping row never squeezes anything — flex moves an item to the
// next line instead of taking it below its content width — so wrapping here would hand the
// trail a line of its own and leave it whole, which is the opposite of what a narrow bar
// wants: the trail truncating from its start.
phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`,
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there.
content?.actionsFill && 'pr-0',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
navHidden && 'pl-4',
'flex flex-col shrink-0 min-w-0 bg-surface transition-shadow duration-150',
showEdge &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
{#if (navDetached.val || panelled) && !navHidden}
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
<div
class={twMerge(
'flex items-center gap-1 min-w-0',
// No flex-wrap. A wrapping row never squeezes anything — flex moves an item to the next line
// instead of taking it below its content width — so wrapping here would hand the trail a
// line of its own and leave it whole. What needs a line of its own asks for one through
// `belowBar`, at a fold the bar picks.
phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`,
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there.
content?.actionsFill && 'pr-0',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
navHidden && 'pl-4'
)}
>
{#if (navDetached.val || panelled) && !navHidden}
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
is on the handle, on the card, or in a menu either opened, and sliding back out when it
leaves. A click or tap pins it instead, until another one here, a press on the page or Esc
puts it back. Attaching
it for good belongs to the toggle in the sidebar's own footer, where detaching it
happened. 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()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-nav-handle
bind:clientWidth={handleWidth}
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
selected={navHandleSlot.pinned}
aria-pressed={navHandleSlot.pinned}
onclick={() => navHandleSlot.toggle()}
/>
</div>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
selected={navHandleSlot.pinned}
aria-pressed={navHandleSlot.pinned}
onclick={() => navHandleSlot.toggle()}
/>
</div>
{/if}
<!-- The breadcrumb yields width more slowly than the page's controls (shrink-[0.3]) but it does
yield, at every width: the trail truncates from its start, so the squeeze is spent on the
workspace and the folders before it ever reaches the name of the thing the page is about.
The 5rem floor is what is left when every name in it has gone — the workspace disc, the
fork mark and the environment badge, which is as small as the part can be and still say
where the user is.
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
and caps the names left, so this box only shrinks past what they add up to on a bar too
narrow for even the last stage.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to.
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
instead. -->
<div
class={twMerge(
'flex shrink-[0.3] min-w-[5rem]',
!hasSummary &&
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
'shrink'
)}
>
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
page the controls beside it belong to. -->
<div class="flex min-w-0">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
registry already tolerates a registration that cannot answer while its owner's data is
being cleared (see `read`); the same window reaches rendering, and an error here would
otherwise travel to the root and blank the app rather than the one fragment. A
boundary per fragment keeps that to the fragment. -->
<svelte:boundary onerror={(e) => console.error('page header: breadcrumb failed to render', e)}>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{narrow}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{#if item && hasSummary}
<!-- 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={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<!-- A dot rather than a slash: the summary names the same item the path just located, it
is not another level of it. Inside this box so that it narrows with the summary. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
<svelte:boundary onerror={(e) => console.error('page header: summary failed to render', e)}>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
{:else}
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
{/if}
<svelte:boundary
onerror={(e) => console.error('page header: breadcrumb failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{stage}
caps={fit.caps}
scopesGive={fit.scopesGive}
bind:measures={trailMeasures}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{/if}
{#if actions.length > 0}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
{#if item && hasSummary}
<!-- 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 shrink-0">
<!-- A dot after the path: the summary names the same item the path just located, it is not
another level of it. With the path dropped it follows the workspace, and the slash is
the trail's own separator. Fixed width, so either fits the width measured below. -->
<span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true"
>{stage.path || !summaryNamesItem ? '·' : '/'}</span
>
{#if item.summaryContent}
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
wide the page's control wants to be. -->
<div
class="flex min-w-0 overflow-hidden"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
bind:clientWidth={
() => summaryContentWidth,
(w) => fit.caps.summary === undefined && (summaryContentWidth = w)
}
>
<svelte:boundary
onerror={(e) => console.error('page header: summary failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{:else}
<span
class="truncate text-xs font-medium text-emphasis"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
>{item.summary}</span
>
{/if}
</div>
{/if}
{#if actions.length > 0}
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
can give width back, which it can only do if this box may shrink. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
{@attach measureActions(fold, actionsEpoch, actionsVersion)}
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'),
actionsUnmeasured && 'invisible'
)}
>
{#each actions as entry, i (i)}
<svelte:boundary
onerror={(e) => console.error('page header: actions failed to render', e)}
>
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
</svelte:boundary>
{/each}
</div>
{/if}
{#if item && hasSummary}
<!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is
what the bar caps it from. A page's own control stands in at the width last seen. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
<div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}>
<span class="shrink-0 w-2 text-center text-hint/40 text-xs">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
{:else}
<span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px"
>{item.summary}</span
>
{/if}
</div>
<span
class="inline-block whitespace-nowrap text-xs font-medium"
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
>
</div>
{/if}
</div>
{#if content?.belowBar && fit.fold >= (content.belowBarFold ?? 0)}
<!-- Same gutter as the row above, so what moved down lines up with the breadcrumb's start. -->
<div
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
)}
class={twMerge('flex items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')}
>
{#each actions as entry, i (i)}
<svelte:boundary onerror={(e) => console.error('page header: actions failed to render', e)}>
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
</svelte:boundary>
{/each}
<svelte:boundary
onerror={(e) => console.error('page header: second row failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render content.belowBar()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{/if}
</div>
@@ -18,6 +18,11 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
@@ -33,6 +38,11 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
actionsVersion,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
+43 -41
View File
@@ -35,9 +35,8 @@
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { RUNS_PATH } from '$lib/components/sessions/previewPaths'
import { twMerge } from 'tailwind-merge'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
@@ -612,7 +611,12 @@
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(!hosted && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first
// the counters, which are a glance and repeat the chart below; then the session button; then
// the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row
// is the page's own and wraps instead.
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
const filterBelow = $derived(fold >= 3)
</script>
{#snippet runsHint()}
@@ -624,18 +628,34 @@
</Tooltip>
{/snippet}
{#snippet runsFilter(cls: string)}
<FilterSearchbar
class={cls}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus={!hosted}
/>
{/snippet}
{#snippet filterRow()}
{@render runsFilter('relative w-full min-w-0')}
{/snippet}
{#snippet headerActions()}
<!-- The page's own row inside the band's actions box: these controls are denser than a page's
two or three buttons, so they take a wider gap than the band's own. `flex-1 min-w-0` passes
the band's flexibility down to the search field, the one control here that gives width
back; the rest keep theirs or drop out of the row entirely at a width. -->
<div class="flex items-center gap-3 flex-1 min-w-0 {hosted ? 'flex-wrap justify-end' : ''}">
two or three buttons, so they take a wider gap than the band's own. Every control keeps its
width: the bar measures the row and folds it rather than squeezing it. -->
<div class="flex items-center gap-3 {hosted ? 'flex-wrap justify-end' : ''}">
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
so without it the badge butts against the first filter.
A phone's bar has room for the filter field and the timeframe and nothing else: the
counters are a glance, and the same numbers are on the chart below. -->
<div class="flex items-center pr-2 {phone ? 'hidden' : ''}">
so without it the badge butts against the first filter. -->
<div class="flex items-center pr-2 {fold >= 1 ? 'hidden' : ''}">
<RunsQueue
success={filters.val.status ?? null}
{queue_count}
@@ -806,32 +826,10 @@
</div>
</div>
<!-- The one control in the band that gives way: everything else keeps the width it asked
for, or drops out of the row at a width, and this takes what is left. -->
<FilterSearchbar
class={twMerge(
// A width of its own, and only ever less. As `flex-1` the field was whatever the row
// had left over, so a chip appearing or a filter committing resized it under the
// cursor and the last pixels came off the breadcrumb. 18rem is its width whenever the
// row fits, and it still shrinks when the row runs short, which is what keeps the row
// inside a box the bar lets shrink (`actionsFlexible`). `min-w-0` is what makes that
// shrink unconditional: a flex item's floor is its min-content width, which the chips
// inside the field raise, so without it a typed filter would push the field wider
// again exactly when the row has no room to give.
'relative w-[18rem] min-w-0 max-w-full',
// A phone's bar cannot spare that much before the timeframe beside it.
phone && 'w-[10rem]'
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus={!hosted}
/>
<!-- A width of its own, never what the row has left over: as `flex-1` a chip appearing or a
filter committing resized it under the cursor. `shrink-0` keeps it at that width, which
is the row's width the bar measures and folds. -->
{#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if}
<TimeframeSelect
unifiedSize="sm"
onClick={() => jobsLoader?.loadJobs(true)}
@@ -842,7 +840,7 @@
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<div class={phone || hosted ? 'hidden' : 'contents'}>
<div class={fold >= 2 || hosted ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
@@ -923,14 +921,18 @@
</div>
{:else}
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
<!-- `actionsFlexible`: the filter row holds a search field that can give width back, so the
bar lets this box shrink on a phone instead of holding its content width. -->
{#if hosted}
<div class="flex px-4 pt-3 pb-2">
{@render headerActions()}
</div>
{:else}
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
belowBar={filterRow}
belowBarFold={3}
/>
{/if}
<!-- Graph -->
@@ -96,7 +96,7 @@
import EditorHeader from './EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
@@ -206,19 +206,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full button group is
// ~515px — about 1100px between them, plus slack for a longer path than most.
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
)
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
@@ -2316,6 +2311,7 @@
// route binds here and drops the `{#if}` around this editor in one flush, which
// invalidates the band before this component is torn down.
path: pathSnapshot ?? script?.path,
summary: script?.summary,
summaryContent: scriptSummary,
// Gated like the summary's own editor: a host that allows neither field keeps the
// band's segment, which copies the path.
@@ -2326,6 +2322,7 @@
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
actionFolds={2}
separator="none"
/>
{:else}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { emptyString, isOwner } from '$lib/utils'
import { Alert, Button } from '$lib/components/common'
import { Alert, Button, CopyButton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Path from '$lib/components/Path.svelte'
@@ -326,14 +326,62 @@
<!-- Nothing to open, but the host still drew this into a slot of its own: give back what it
handed over rather than this component's own block, which belongs in a row it owns. -->
{@render label()}
{:else if compact}
<!-- Nothing to edit, but the band truncates the summary and, on a short bar, drops the path:
the same click shows both whole, to read and to copy. -->
<Popover
class={triggerClass}
placement="bottom-start"
contentClasses="p-4"
triggerAttrs={{
title: emptyString(summary) ? path : summary,
'aria-label': 'Show summary and path'
}}
usePointerDownOutside
excludeSelectors=".drawer"
>
{#snippet trigger()}
<span class="block truncate text-xs font-medium text-emphasis">
{emptyString(summary) ? (path ?? '') : summary}
</span>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-4 w-[420px] max-w-[calc(100vw-2rem)]">
<div>
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
{#if emptyString(summary)}
<p class="text-xs text-tertiary italic">No summary</p>
{:else}
<p class="text-xs text-primary break-words">{summary}</p>
{/if}
</div>
<div>
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<div class="flex items-center gap-1">
<span class="min-w-0 text-xs font-mono text-secondary break-all">{path}</span>
<CopyButton value={path ?? ''} title="Copy path" />
</div>
</div>
{#if labels?.length || inheritedLabels?.length}
<div class="flex flex-wrap items-center gap-0.5">
{#each labels ?? [] as labelText}
<Badge color="blue" verySmall class="px-1" title="Label: {labelText}"
>{labelText}</Badge
>
{/each}
<InheritedLabels labels={inheritedLabels} />
</div>
{/if}
<p class="text-2xs text-tertiary">You can't edit this {kind}'s summary or path.</p>
</div>
{/snippet}
</Popover>
{:else}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
<div class="min-w-0 truncate flex items-center flex-col px-2">
{#if !emptyString(summary)}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
>
<span class="text-sm font-semibold text-emphasis truncate">
{emptyString(summary) ? (path ?? '') : summary}
</span>
</div>
@@ -54,7 +54,7 @@
import EditorHeader from '$lib/components/EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
@@ -261,24 +261,16 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
// with the summary takes ~430px of the same row.
// Measured on the band rather than estimated: with the canvas toggles shown the actions alone
// are 826-911px and the trail with the summary another ~500, so the row overflows the bar at
// every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand
// down and the menu carries all three, which is what keeps them reachable rather than merely
// out of the way.
const compactBelow = $derived(ownsPageHeader ? 1500 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas
// toggles stand down and the menu carries them and Debug runs, which keeps them reachable
// rather than merely out of the way.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -1010,10 +1002,12 @@
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined),
summary: $summary,
summaryContent: appSummary,
pathTrigger: appPathTrigger
}}
actions={appHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="always"
/>
@@ -17,14 +17,16 @@
type MainButton = {
label: string
/** Shown under the label once the button has collapsed into a menu. */
/** Shown under the label once the button is a row of a menu. */
description?: string
buttonProps: ButtonProps
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
* width. */
narrow?: 'menu' | { dropdownOf: string }
/** A secondary action: a bar button while the bar has room for every one, then, from the
* first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled
* `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to
* the menu only on the folds after that. */
placement?: 'menu' | { dropdownOf: string }
/** In its `placement` at every width, never a bar button of its own. */
alwaysPlaced?: boolean
}
type ButtonProps = any
@@ -70,6 +72,9 @@
wide?: boolean
/** Controls ahead of the menu, such as the way an agent's page runs it. */
leading_actions?: import('svelte').Snippet
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
* summary rather than among the badges and buttons at the far end. */
name_marks?: import('svelte').Snippet
}
let {
@@ -89,7 +94,8 @@
trigger_badges,
ownsPageHeader = false,
wide = true,
leading_actions
leading_actions,
name_marks
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -98,26 +104,50 @@
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
// own box is narrow while the window is not — a session's preview panel — and says so through
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
// the buttons out of the menu a frame later.
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
const COLLAPSE_BELOW = 1250
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
const wideRow = $derived(wide && roomInBar)
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
// first the secondary ones go to their `placement`, then every bar button but the last, which
// is what the page is for (Edit), then that one too. Outside the band — a session's preview
// panel — no bar measures the row, but the host knows when its own box is narrow while the
// window is not and says so through `wide`: the secondary buttons fold there, as at the first
// fold, and the error handler's toggle goes to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : wide ? 0 : 1)
const wideRow = $derived(wide && fold <= 1)
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
const barButtons = $derived(
fold === 0
? mainButtons.filter((b) => !b.alwaysPlaced)
: fold === 1
? rowButtons
: fold === 2
? rowButtons.slice(-1)
: []
)
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.narrow !== 'object') return undefined
const label = btn.narrow.dropdownOf
if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined
const label = btn.placement.dropdownOf
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
}
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
* dropdown would have held, then the secondary actions with no bar button to hang from. */
const menuButtons = $derived.by(() => {
const folded = rowButtons.filter((b) => !barButtons.includes(b))
const homeless = mainButtons.filter(
(b) => b.placement && !barButtons.includes(b) && !dropdownHost(b)
)
const childOf = (host: MainButton) =>
homeless.filter(
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
)
const placed = new Set(folded.flatMap(childOf))
return [
...folded.flatMap((host) => [host, ...childOf(host)]),
...homeless.filter((b) => !placed.has(b))
]
})
async function toggleErrorHandler() {
if (!errorHandlerKind || !scriptOrFlowPath) return
const next = await toggleWorkspaceErrorHandler(
@@ -130,7 +160,7 @@
}
const allMenuItems: Item[] = $derived([
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
...menuButtons.map((b) => ({
displayName: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
@@ -155,12 +185,11 @@
action: item.onclick,
disabled: item.disabled,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wideRow
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
}))
])
function dropdownItemsOf(host: MainButton) {
if (wideRow) return undefined
const items = mainButtons
.filter((b) => dropdownHost(b) === host)
.map((b) => ({
@@ -176,15 +205,18 @@
</script>
{#snippet summaryContent()}
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
<div class="flex items-center gap-1 min-w-0">
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
</div>
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
@@ -272,12 +304,17 @@
item={{
kind: errorHandlerKind,
path,
// Read-only, the summary control falls back to the path when there is no summary, so the
// path is what names the item there and the trail's own copy can go.
summary: onSaved ? summary : summary || path,
summaryContent,
// Only where this page can save one: without `onSaved` the editor behind it has nothing
// to write, and the band keeps its own segment, which copies the path.
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
actionFolds={3}
earlyActionFolds={1}
contexts={headerContexts}
separator="always"
/>
@@ -301,6 +338,7 @@
kind={renameKind}
/>
</div>
{@render name_marks?.()}
{@render badges('md')}
</div>
<div class="flex gap-1 items-center pr-4">
@@ -369,7 +369,7 @@
buttons.push({
label: 'Fork',
description: `Start a new flow from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/flows/add?template=${flow.path}`),
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
@@ -390,7 +390,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('flow', flow.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, flow.path),
@@ -418,7 +418,7 @@
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => flowHistory?.open(),
unifiedSize: 'md',
@@ -435,7 +435,8 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
@@ -682,6 +683,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && flow && workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -698,12 +705,6 @@
small={false}
/>
{/snippet}
{#if workspace && flow}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/if}
<OnBehalfOfBadge
onBehalfOf={flow?.on_behalf_of}
onBehalfOfEmail={flow?.on_behalf_of_email}
@@ -532,7 +532,7 @@
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/scripts/add?template=${script.path}`),
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
@@ -553,7 +553,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('script', script.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, script.path),
@@ -586,7 +586,7 @@
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
@@ -602,7 +602,8 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
@@ -870,6 +871,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && script && workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -887,12 +894,6 @@
}}
/>
{/snippet}
{#if workspace && script}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/if}
{#if script?.auto_kind === 'wac'}
<Popover notClickable>
{#snippet text()}
@@ -318,6 +318,7 @@
<PageHeaderContent
item={{
path: pathSnapshot ?? shownPath,
summary: draft?.state?.description,
summaryContent: agentSummary,
// Only where the rename behind it can be saved: a reader who cannot write this agent
// keeps the segment that copies the path, rather than an editor with nothing to edit.
@@ -52,6 +52,26 @@ export type PageHeaderContent = {
* controls share one shape that opens into the page below. The bar gives up its right padding
* to it, since the surface is what meets the edge. */
actionsFill?: boolean
/** How many levels past the full row these actions can fold through, each a narrower row than
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
* never fold and the breadcrumb gives up width around them. */
actionFolds?: number
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
* that are there only while the bar has room for everything. */
earlyActionFolds?: number
/** Changes whenever the actions may have changed width at a level that is not on screen — a
* run's Cancel, folded into a menu, going once the job completes. The bar keeps what it
* measured per version, so a new one is measured afresh rather than judged by stale widths. */
actionsVersion?: string
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
* until it is useless. The band is taller while it is there. */
belowBar?: Snippet
/** The fold from which the bar shows `belowBar`. The bar decides rather than the page passing
* the row in only at that fold: the fold is derived from this registration, so a registration
* that read it would be a cycle. */
belowBarFold?: number
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
@@ -91,10 +111,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
/**
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
* line sits above the tablet rather than between the two.
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
*/
export const PHONE_BAR = 800
@@ -112,6 +131,9 @@ export const BAR_HEIGHT_PX = 48
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
// which the sidebar and a page's side panel both take from.
let barWidth = $state(0)
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
// over a getter rather than writing a copy that an effect would have to keep in step.
let actionFoldSource = $state<(() => number) | undefined>(undefined)
/**
* One registration's content, or undefined while it cannot answer.
@@ -151,6 +173,11 @@ export const pageHeader = {
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
if (c.separator !== undefined) merged.separator = c.separator
}
return merged
@@ -176,6 +203,17 @@ export const pageHeader = {
setBarWidth(w: number) {
barWidth = w
},
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
get actionFold(): number {
return actionFoldSource?.() ?? 0
},
/** Called by the bar once; returns the release to call when the bar goes away. */
provideActionFold(get: () => number): () => void {
actionFoldSource = get
return () => {
if (actionFoldSource === get) actionFoldSource = undefined
}
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
const base: BarMeasure = {
barWidth: 2000,
chrome: 0,
trail,
summary: BAR_STAGES.map(() => 0),
actions: [300, 200, 100],
folds: 2,
natural: { family: 0, fork: 0, summary: 0, name: 0 }
}
describe('fitBar', () => {
it('picks the first stage that fits beside the full row of actions', () => {
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
})
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
})
it('spends early folds before the breadcrumb drops anything', () => {
// 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have
// dropped parts beside the full row instead.
expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 })
// Past that, the breadcrumb gives way at the early fold before the later folds start.
expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 })
})
it('tries a level it has not seen at the width of the next one folded further', () => {
// Only the second fold has been measured: the levels before it are assumed no wider, so the
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
expect(fitBar(unseen).fold).toBe(0)
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
})
it("takes the squeeze from the path's name before the summary", () => {
const fit = fitBar({
...base,
barWidth: 360,
actions: [0],
folds: 0,
trail: trail.map((w) => w - 400),
summary: BAR_STAGES.map(() => 100),
natural: { family: 0, fork: 0, summary: 300, name: 300 }
})
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
// has to go: all 140 the name has above its floor, then 40 from the summary.
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
})
it('keeps the summary and drops the path when only one of them fits', () => {
const natural = { family: 0, fork: 0, summary: 300, name: 300 }
// Stage 2 is the first without the path: the name is no longer a part the bar sizes.
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
expect(fit.stage).toBe(2)
expect(fit.caps.name).toBeUndefined()
// A placeholder offering to add a summary names nothing: the path stays a part the bar sizes.
const placeholder = fitBar({
...base,
barWidth: 520,
natural,
actions: [0],
folds: 0,
summaryNamesItem: false
})
expect(placeholder.caps.name).toBeDefined()
})
})
@@ -0,0 +1,213 @@
/**
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
* stage the page's actions fold, one level at a time; past their last level the item's name and
* its summary truncate, then the folders above it.
*/
/** What the breadcrumb itself drops. */
export type TrailStage = {
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
* name and stays until `wsButton`. */
familyName: boolean
/** The item's path. Dropped only for an item that has a summary: the summary is what a
* person calls the thing, while its path is where it is filed. Without one, the path is the
* item's only name and stays. */
path: boolean
forkName: boolean
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
wsButton: boolean
/** The folders between the first one and the name; collapsed, they read as `…`. */
middleLevels: boolean
}
export type BarStage = TrailStage & { label: string }
export const BAR_STAGES: BarStage[] = [
{
label: 'Full',
familyName: true,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Family name dropped',
familyName: false,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Path dropped for the summary',
familyName: false,
path: false,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Fork name dropped',
familyName: false,
path: false,
forkName: false,
wsButton: false,
middleLevels: true
},
{
label: 'Workspace as its disc',
familyName: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: true
},
{
label: 'Middle folders collapsed',
familyName: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: false
}
]
export type Part = 'family' | 'fork' | 'summary' | 'name'
/** The trail at each stage with its names at their floors, and each name's own width. */
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
/** The width a part may truncate to before the next stage drops something whole. The summary's
* is wide: below it the path goes rather than the summary losing most of what it says. */
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
/** Past the last stage and fold, the item's name and then its summary keep this much before the
* folders give any. */
export const SQUEEZED_FLOOR = 40
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
const scopes = levels.slice(0, -1)
if (stage.middleLevels || scopes.length <= 1) return levels
return [scopes[0], '…', levels[levels.length - 1]]
}
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
export function partVisible(
part: Part,
stage: BarStage,
natural: Record<Part, number>,
summaryNamesItem = true
): boolean {
if (natural[part] <= 0) return false
switch (part) {
case 'summary':
return true
case 'family':
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
case 'fork':
return !stage.wsButton && stage.forkName
case 'name':
return stage.path || natural.summary <= 0 || !summaryNamesItem
}
}
export type BarMeasure = {
barWidth: number
/** The bar's padding and the sidebar handle: what is there at every stage. */
chrome: number
/** Per stage: the breadcrumb with each name at its floor. */
trail: number[]
/** Per stage: the summary at its floor with the separator before it, 0 where there is none. */
summary: number[]
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
actions: (number | undefined)[]
/** How many levels past the full row the page's actions can fold through. */
folds: number
/** How many of those levels fold before the breadcrumb drops anything: buttons that are there
* only while there is room for everything. */
earlyFolds?: number
natural: Record<Part, number>
/** The summary is the item's own text, not a placeholder offering to add one: only then does it
* name the item well enough for the path to go. */
summaryNamesItem?: boolean
}
export type BarFit = {
stage: number
fold: number
/** Max widths for the parts that give width, in the order they give it. */
caps: Partial<Record<Part, number>>
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
scopesGive: boolean
}
/**
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
* instead and the deficit is taken from them in a strict order, each down to its floor.
*/
export function fitBar(m: BarMeasure): BarFit {
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
// gets measured, and once it has been, the bar never picks it again unless it fits.
const actionsAt = (fold: number) => {
for (let f = fold; f < m.actions.length; f++)
if (m.actions[f] !== undefined) return m.actions[f]!
return 0
}
const last = BAR_STAGES.length - 1
const floorWidth = (s: number, f: number) =>
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
const early = Math.min(m.earlyFolds ?? 0, m.folds)
let stage = last
let fold = m.folds
choose: {
for (let f = 0; f < early; f++) {
if (floorWidth(0, f) <= m.barWidth) {
stage = 0
fold = f
break choose
}
}
for (let s = 0; s <= last; s++) {
if (floorWidth(s, early) <= m.barWidth) {
stage = s
fold = early
break choose
}
}
// The other levels fold only once the breadcrumb has given all it can, so a narrowing bar
// only ever loses things: the parts it dropped do not come back when the buttons fold.
for (let f = early + 1; f <= m.folds; f++) {
if (floorWidth(last, f) <= m.barWidth) {
fold = f
break choose
}
}
}
const at = BAR_STAGES[stage]
// The summary gives last: it is what a person calls the item, so the path's name goes first.
const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) =>
partVisible(p, at, m.natural, m.summaryNamesItem ?? true)
)
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
// The stage was measured with its parts at their floors; this is its width with them whole.
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
const caps: Partial<Record<Part, number>> = {}
const take = (p: Part, floor: number) => {
if (deficit <= 0 || !parts.includes(p)) return
const current = caps[p] ?? m.natural[p]
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
if (give > 0) caps[p] = current - give
deficit -= give
}
for (const p of parts) take(p, FLOOR[p])
take('name', SQUEEZED_FLOOR)
take('summary', SQUEEZED_FLOOR)
return { stage, fold, caps, scopesGive: deficit > 0 }
}
@@ -80,7 +80,7 @@
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -318,20 +318,15 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
// Jobs, Export, AI and Deploy — is ~560px.
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
@@ -914,8 +909,8 @@
</a>
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
it can be fetched (S3, GitHub raw, your docs site) and link
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill
keeps no copy.
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill keeps
no copy.
</span>
</div>
<div class="flex-1 min-h-0">
@@ -956,10 +951,12 @@
// rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
summary,
summaryContent: appSummary,
pathTrigger: rawAppPathTrigger
}}
actions={rawAppHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
+236 -34
View File
@@ -20,7 +20,8 @@
isHubFlowPath,
isFlowPreview,
isNotFlow,
isScriptPreview
isScriptPreview,
type Item
} from '$lib/utils'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -714,10 +715,197 @@
onDestroy(resetFavicon)
}
// The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way
// grudgingly, so the buttons drop their labels rather than push each other off the end.
// Embedded, the buttons have a row of their own that wraps.
const compactBar = $derived(!embedded && pageHeader.barWidth > 0 && pageHeader.barWidth < 1150)
// The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: first the
// buttons drop their labels, then the plain actions go into one menu. Embedded, the buttons
// have a row of their own that wraps.
const compactBar = $derived(!embedded && pageHeader.actionFold >= 1)
const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 2)
const isScriptJob = $derived(job?.job_kind === 'script')
const isRunnableJob = $derived(
job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'
)
const isHubFlowPreview = $derived(isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path))
const runsHref = $derived(`/runs/${job?.script_path}${!isScriptJob ? '?jobKind=flow' : ''}`)
const stem = $derived(job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`)
// A script is addressed by the hash this run executed. Embedded, that goes in the query: the
// host's tabs are keyed by an item's path, and a hash there would be a tab on no item.
const viewHref = $derived(
isScriptJob && embedded
? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
: `${stem}/get/${isScriptJob ? job?.script_hash : job?.script_path}`
)
const editHref = $derived(`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`)
const forkPreviewLabel = $derived(
isHubFlowPreview
? 'Fork flow into workspace'
: `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`
)
const viewLabel = $derived(`View ${job?.job_kind === 'script_hub' ? 'script' : job?.job_kind}`)
const canDelete = $derived(
!!job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))
)
const canCancel = $derived(
!!job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)
)
const canDebug = $derived(
(job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) &&
!!job?.['running'] &&
job?.parent_job == undefined
)
const canEdit = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
!actingUser?.operator &&
editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})
)
const canEditInFork = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
!actingUser?.operator &&
!showEditButton &&
!isCloudHosted() &&
editInForkAllowed(workspaceId, $userWorkspaces)
)
/** Which actions the row has: they come and go as the job runs and completes, often while the
* row is folded into the menu, where the bar cannot see them change (`actionsVersion`). */
const actionsVersion = $derived(
[
canDelete,
canCancel,
forceCancel,
canDebug,
canRunNow,
canEdit,
canEditInFork,
dbtResumable,
!!job?.schedule_path,
persistentScriptDefinition !== undefined,
restartStep !== undefined && canRestart(restart)
]
.map(Number)
.join('')
)
async function runAgain() {
// The form this lands on rebuilds the whole project. When resuming THIS run is the cheaper
// thing to do, name it so the form can offer that in one click rather than leaving the
// reader to know.
const from = dbtResumable
? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
: ''
onNavigate(
viewHref + from + `#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
)
}
function cancel() {
if (!job?.id) return
cancelJob(job.id)
// The first cancel asks the job to stop; if it has not after three seconds, the same
// control offers to force it.
if (!forceCancel) setTimeout(() => (forceCancel = true), 3001)
}
// The same actions as the row, most used first; the restart and session controls stay beside
// the menu, since each opens a popover of its own that a menu row cannot hold.
const menuItems = $derived.by((): Item[] => {
if (!job) return []
const kind = isScriptJob ? 'script' : 'flow'
return [
{ displayName: viewLabel, icon: Code2, href: viewHref, hide: !isRunnableJob },
{ displayName: 'Run again', icon: RefreshCw, action: runAgain, hide: !isRunnableJob },
{
displayName: 'dbt retry with same args',
icon: RefreshCw,
action: resumeDbtRun,
hide: !isRunnableJob || !dbtResumable
},
{
displayName: 'Run immediately with same args',
icon: RefreshCw,
action: () => runImmediately(),
hide: !isRunnableJob
},
{
displayName: 'Edit',
icon: Pen,
href: editHref,
action: () => ($initialArgsStore = job?.args),
disabled: !showEditButton,
hide: !canEdit
},
{
displayName: editInForkLabel(workspaceId, $userWorkspaces),
icon: Pen,
href: buildForkEditUrl(kind, job.script_path ?? '', workspaceId),
action: (e) => editInFork(e, kind, job?.script_path ?? ''),
hide: !canEditInFork
},
{
displayName: 'Run now',
icon: Play,
action: () => job?.id && runJobNow(job.id),
hide: !canRunNow
},
{
displayName: 'Edit schedule',
icon: Calendar,
action: () =>
job?.schedule_path && scheduleEditor?.openEdit(job.schedule_path, job.job_kind == 'flow'),
hide: !job.schedule_path
},
{
displayName: forkPreviewLabel,
icon: GitBranch,
action: forkPreview,
hide: !(isFlowPreview(job.job_kind) || isScriptPreview(job.job_kind))
},
{
displayName: 'Current runs',
icon: Activity,
action: () => persistentScriptDrawer?.open?.(persistentScriptDefinition),
hide: persistentScriptDefinition === undefined
},
{
displayName: 'View runs',
icon: List,
href: runsHref,
hide: !canDelete || !(job.job_kind === 'script' || job.job_kind === 'flow')
},
{
displayName: 'Copy link for members',
icon: Users,
action: () => job && shareReadLink(job.id),
separatorTop: true
},
{
displayName: 'Copy public link',
icon: Globe,
disabled: !canSharePublicly,
action: () => job && sharePublicLink(job.id)
},
{
displayName: 'Show flow debug info',
icon: EllipsisVertical,
action: debugInfo,
hide: !canDebug
},
{
displayName: forceCancel ? 'Force cancel' : 'Cancel',
icon: TimerOff,
type: 'delete',
action: cancel,
hide: !canCancel,
separatorTop: true
},
{
displayName: 'Delete result, logs and args (admin only)',
icon: Trash,
type: 'delete',
action: () => job?.id && deleteCompletedJob(job.id),
hide: !canDelete
}
]
})
</script>
<HighlightTheme />
@@ -890,14 +1078,47 @@
{@render runActions()}
</div>
{:else}
<PageHeaderContent section={{ label: `run/${runId}` }} actions={runActions} />
<PageHeaderContent
section={{ label: `run/${runId}` }}
actions={runActions}
actionFolds={2}
{actionsVersion}
/>
{/if}
{#snippet runActions()}
{@const isScript = job?.job_kind === 'script'}
{@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)}
{@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`}
{#if job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))}
{#if actionsInMenu}
{#if restartStep !== undefined && canRestart(restart)}
{@render flowRestartButton(restart, restartStep, 'button')}
{/if}
{#if canEdit && showEditButton}
<OpenInSessionButton
source={{
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: workspaceId ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
{/if}
<Dropdown items={menuItems} customWidth={260}>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="sm"
iconOnly
title="Run actions"
startIcon={{ icon: EllipsisVertical }}
/>
{/snippet}
</Dropdown>
{:else}
{@render runActionsRow()}
{/if}
{/snippet}
{#snippet runActionsRow()}
{#if canDelete}
<Dropdown
items={[
{
@@ -962,13 +1183,6 @@
{/snippet}
</Dropdown>
{/if}
{@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
<!-- A script is addressed by the hash this run executed. Embedded, that goes in the query:
the host's tabs are keyed by an item's path, and a hash there would be a tab on no item. -->
{@const viewHref =
isScript && embedded
? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
: `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}
<div class="inline">
<Dropdown
@@ -1086,19 +1300,7 @@
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
<Button
on:click={async () => {
// The form this lands on rebuilds the whole project. When resuming
// THIS run is the cheaper thing to do, name it so the form can
// offer that in one click rather than leaving the reader to know.
const from = dbtResumable
? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
: ''
onNavigate(
viewHref +
from +
`#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
)
}}
on:click={runAgain}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
@@ -1122,7 +1324,7 @@
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
{#if !actingUser?.operator}
{#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})}
{#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})}
<Button
href={`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`}
on:click={() => {
@@ -1141,7 +1343,7 @@
Edit: where direct deployment is off, the way in is "Edit in fork". -->
<OpenInSessionButton
source={{
target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' },
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: workspaceId ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
@@ -1151,11 +1353,11 @@
{#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)}
<Button
href={buildForkEditUrl(
isScript ? 'script' : 'flow',
isScriptJob ? 'script' : 'flow',
job?.script_path ?? '',
workspaceId
)}
onClick={(e) => editInFork(e, isScript ? 'script' : 'flow', job?.script_path ?? '')}
onClick={(e) => editInFork(e, isScriptJob ? 'script' : 'flow', job?.script_path ?? '')}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}