From 78485aa23b9604fa28d4f18a38226cd71801d0c2 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 08:05:23 -0700 Subject: [PATCH] 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) --- .../src/lib/components/PageHeaderBar.svelte | 298 ++++++++++-------- .../lib/components/PageHeaderContent.svelte | 4 + frontend/src/lib/components/RunsPage.svelte | 83 ++--- .../components/pageHeaderRegistry.svelte.ts | 10 + 4 files changed, 218 insertions(+), 177 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 56377d6382..af6dfba47a 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -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} - - -
navHandleSlot.open()} - onmouseleave={() => navHandleSlot.scheduleClose()} - > - +
navHandleSlot.open()} + onmouseleave={() => navHandleSlot.scheduleClose()} + > + -
- {/if} +
+ {/if} - -
- - - console.error('page header: breadcrumb failed to render', e)}> - {#key content?.contexts} - - - - {/key} - -
+ console.error('page header: breadcrumb failed to render', e)} + > + {#key content?.contexts} + + + + {/key} + + - {#if item && hasSummary && stage.summary} - -
- - - {#if item.summaryContent} - -
summaryContentWidth, - (w) => fit.caps.summary === undefined && (summaryContentWidth = w) - } - > - console.error('page header: summary failed to render', e)} - > - {#key content?.contexts} - - {@render item.summaryContent()} - - {/key} - -
- {:else} - {item.summary} - {/if} -
- {/if} - - {#if actions.length > 0} - - -
- {#each actions as entry, i (i)} - console.error('page header: actions failed to render', e)}> - {#key entry.contexts} - - {@render entry.render()} - - {/key} - - {/each} -
- {/if} - - {#if item && hasSummary} - - + {#if content?.belowBar && fit.fold >= (content.belowBarFold ?? 0)} + +
+ console.error('page header: second row failed to render', e)} + > + {#key content?.contexts} + + {@render content.belowBar()} + + {/key} +
{/if} diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 3d95e74796..76dd0834b0 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -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, diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 7a130869b5..73f715d321 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -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) {#snippet runsHint()} @@ -615,18 +618,34 @@ {/snippet} +{#snippet runsFilter(cls: string)} + +{/snippet} + +{#snippet filterRow()} + {@render runsFilter('relative w-full min-w-0')} +{/snippet} + {#snippet headerActions()} -
+ 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. --> +
-
+ so without it the badge butts against the first filter. --> +
- - + + {#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if} jobsLoader?.loadJobs(true)} @@ -833,7 +830,7 @@ -
+
= 2 ? 'hidden' : 'contents'}> RUNS_PATH + window.location.search, @@ -913,9 +910,13 @@
{:else}
- - +
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 77d43ff362..cd33b67adb 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -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