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>
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:05:23 -07:00
co-authored by Claude Opus 5.5
parent 23f6e69bf2
commit 78485aa23b
4 changed files with 218 additions and 177 deletions
+162 -136
View File
@@ -152,173 +152,199 @@ 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. Where no pointer hovers, a tap opens it and it stays until dismissed. 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
bind:clientWidth={handleWidth}
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"
onclick={() => navHandleSlot.open()}
/>
</div>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
onclick={() => navHandleSlot.open()}
/>
</div>
{/if}
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
<!-- 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. -->
<div class="flex min-w-0">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
<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}
{stage}
caps={fit.caps}
scopesGive={fit.scopesGive}
bind:measures={trailMeasures}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
<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 item && hasSummary && stage.summary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
{#if item && hasSummary && stage.summary}
<!-- 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 rather than a slash: the summary names the same item the path just located, it
<div class="flex items-center gap-1 shrink-0">
<!-- A dot rather than a slash: the summary names the same item the path just located, it
is not another level of it. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#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(fit.fold, actionsEpoch)}
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 text-hint/40 text-xs px-0.5">·</span>
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
<!-- 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" style="max-width: {FLOOR.summary}px"
<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>
<span
class="inline-block whitespace-nowrap text-xs font-medium"
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
{/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(fit.fold, actionsEpoch)}
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 text-hint/40 text-xs px-0.5">·</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 items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')}
>
<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>
@@ -19,6 +19,8 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFlexible,
actionsFill,
actionFolds,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
@@ -35,6 +37,8 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFlexible,
actionsFill,
actionFolds,
belowBar,
belowBarFold,
separator,
contexts,
actingWorkspaceId,
+42 -41
View File
@@ -37,9 +37,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'
@@ -603,7 +602,11 @@
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(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.
const fold = $derived(pageHeader.actionFold)
const filterBelow = $derived(fold >= 3)
</script>
{#snippet runsHint()}
@@ -615,18 +618,34 @@
</Tooltip>
{/snippet}
{#snippet runsFilter(cls: string)}
<FilterSearchbar
class={cls}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus
/>
{/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">
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">
<!-- 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}
@@ -797,32 +816,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: $workspaceStore === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus
/>
<!-- 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)}
@@ -833,7 +830,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 ? 'hidden' : 'contents'}>
<div class={fold >= 2 ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
@@ -913,9 +910,13 @@
</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. -->
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
belowBar={filterRow}
belowBarFold={3}
/>
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
@@ -57,6 +57,14 @@ export type PageHeaderContent = {
* 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
/** 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. */
@@ -159,6 +167,8 @@ export const pageHeader = {
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.belowBar !== undefined) merged.belowBar = c.belowBar
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
if (c.separator !== undefined) merged.separator = c.separator
}
return merged