fix: the widest action rows scroll, and the canvas toggles show only where they fit

Measuring the band instead of estimating it: with the canvas toggles shown the
app editor's actions are 826-911px and the trail with the summary another ~500,
so the row overflowed the bar at every width up to about 1470 — the 1100 I set
from an estimate was wrong, and the 1350 before it was too. The toggles come out
at 1500 now, and the menu entries are what keeps all three reachable below that
rather than merely out of the way.

The two widest rows — this editor's and the pipeline's — scroll inside their own
box, which is the escape the rows they replaced had (`overflow-x-auto
whitespace-nowrap`). That answers pi's finding and clears the last 23-40px of
band overflow at middling widths, which predated the conversion. Measured after:
zero horizontal overflow from 852px of bar to 1392.

From the CI round's other findings:
- The pen is offered when either the summary or the path is editable, not the
  path alone: a host that allowed one and refused the other could no longer edit
  the summary from the band. `PathEditPopover` takes `pathEditable` to match, and
  its label names whichever it is given.
- The four editors' comments called the summary read-only, which stopped being
  true when the pen beside it started editing it.
- The pipeline's "centre group" comment described a layout it no longer has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 03:43:31 -07:00
co-authored by Claude Opus 5
parent 72e678a923
commit 1dbe7bf9af
6 changed files with 185 additions and 159 deletions
@@ -1654,9 +1654,8 @@
{/if}
{#snippet flowSummary()}
<!-- Read-only here: the summary is a Settings-tab field, and the band is where the flow
is named rather than where it is edited. The pen sits after it, at the end of what
it renames. -->
<!-- Not edited in place: the pen beside it opens the summary and the path together,
so the band reads as a name rather than a form. `title` for one it truncates. -->
<div class="group flex items-center gap-1 min-w-0">
<span
class="min-w-0 truncate text-xs {emptyString(flowStore.val.summary)
@@ -1665,11 +1664,12 @@
title={flowStore.val.summary}
>{emptyString(flowStore.val.summary) ? 'Add a summary...' : flowStore.val.summary}</span
>
{#if customUi?.topBar?.editablePath != false}
{#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false}
<PathEditPopover
penVisibility={emptyString(flowStore.val.summary) ? 'always' : 'hover'}
bind:summary={flowStore.val.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={$pathStore}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
@@ -30,6 +30,9 @@ to it — does not reflow under the pointer as the user types, which floating-ui
summary?: string
/** When false the summary is shown but not editable, for a host whose `customUi` says so. */
summaryEditable?: boolean
/** When false the path is shown but not editable, the same way. A host may allow one and
* refuse the other, and the pen offers whichever it is given. */
pathEditable?: boolean
path?: string
/** The item's *saved* path on the server, so the popover can say a rename needs deploying. */
savedPath?: string
@@ -47,6 +50,7 @@ to it — does not reflow under the pointer as the user types, which floating-ui
let {
summary = $bindable(),
summaryEditable = true,
pathEditable = true,
path = $bindable(),
savedPath,
kind = 'flow',
@@ -57,10 +61,17 @@ to it — does not reflow under the pointer as the user types, which floating-ui
}: Props = $props()
let open = $state(false)
const penLabel = $derived(
summaryEditable && pathEditable
? 'Edit summary and path'
: summaryEditable
? 'Edit summary'
: 'Edit path'
)
// Treat an empty path as ownable so the popover lets a user pick the path for a brand-new
// item. `Path.reset()` then synthesizes a default under their own user/folder scope.
const own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace))
const own = $derived(pathEditable && (!path || isOwner(path, actingUser, $operatingWorkspace)))
function setOpen(v: boolean) {
open = v
@@ -86,8 +97,8 @@ to it — does not reflow under the pointer as the user types, which floating-ui
unifiedSize="xs"
iconOnly
startIcon={{ icon: Pencil }}
title={summaryEditable ? 'Edit summary and path' : 'Edit path'}
aria-label={summaryEditable ? 'Edit summary and path' : 'Edit path'}
title={penLabel}
aria-label={penLabel}
btnClasses={penVisibility === 'hover' && !open
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
: ''}
@@ -142,7 +153,11 @@ to it — does not reflow under the pointer as the user types, which floating-ui
{:else}
<Label label="Path">
<span class="text-xs font-mono text-secondary">{path}</span>
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
<p class="text-2xs text-tertiary mt-1">
{pathEditable
? 'Only the owner can change the path'
: 'This path cannot be changed here'}
</p>
</Label>
{/if}
</div>
@@ -2338,9 +2338,8 @@
{/if}
{#snippet scriptSummary()}
<!-- Read-only here: the summary is a settings field, and the band is where the script is
named rather than where it is edited. The pen sits after it, at the end of what it
renames. -->
<!-- Not edited in place: the pen beside it opens the summary and the path together,
so the band reads as a name rather than a form. `title` for one it truncates. -->
<div class="group flex items-center gap-1 min-w-0">
<span
class="min-w-0 truncate text-xs {emptyString(script.summary)
@@ -2349,11 +2348,12 @@
title={script.summary}
>{emptyString(script.summary) ? 'Add a summary...' : script.summary}</span
>
{#if customUi?.topBar?.editablePath != false}
{#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false}
<PathEditPopover
penVisibility={emptyString(script.summary) ? 'always' : 'hover'}
bind:summary={script.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
@@ -264,11 +264,12 @@
// 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: the canvas toggles are 240px, the rest of the actions 334, and the trail
// with the summary about 430 — so the whole row fits down to ~1050. 1350 hid the toggles on an
// ordinary laptop (a 1440 window with the rail out leaves 1232), and the menu below is what
// keeps them reachable under this.
const compactBelow = $derived(ownsPageHeader ? 1100 : 720)
// 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
@@ -1020,9 +1021,8 @@
{/if}
{#snippet appSummary()}
<!-- Read-only here: the summary is a deploy-drawer field, and the band is where the app is
named rather than where it is edited. `title` so one the band truncates can still be read.
The pen sits after it, at the end of what it renames. -->
<!-- Not edited in place: the pen beside it opens the summary and the path together,
so the band reads as a name rather than a form. `title` for one it truncates. -->
<div class="group flex items-center gap-1 min-w-0">
<span
class="min-w-0 truncate text-xs {emptyString($summary)
@@ -1043,14 +1043,20 @@
{#snippet appHeaderActions()}
<!-- The bar's own order kept: what the canvas looks like, how the draft is doing, which panels
are open, then what to do with the app. -->
{@render canvasToggles()}
{@render autosaveIndicator()}
{#if !phoneTopbar}
{@render panelButtons()}
{@render awarenessMark()}
{/if}
{@render appActions()}
are open, then what to do with the app. The set scrolls rather than pushing past the end of
the bar — this editor carries the widest action row of any page, and the row it replaced
scrolled for the same reason. -->
<div
class="flex flex-row items-center gap-2 min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap"
>
{@render canvasToggles()}
{@render autosaveIndicator()}
{#if !phoneTopbar}
{@render panelButtons()}
{@render awarenessMark()}
{/if}
{@render appActions()}
</div>
{/snippet}
{#snippet canvasToggles()}
@@ -1021,9 +1021,8 @@
{/snippet}
{#snippet appSummary()}
<!-- Read-only here: the summary is a deploy-drawer field, and the band is where the app is
named rather than where it is edited. `title` so one the band truncates can still be read.
The pen sits after it, at the end of what it renames. -->
<!-- Not edited in place: the pen beside it opens the summary and the path together,
so the band reads as a name rather than a form. `title` for one it truncates. -->
<div class="group flex items-center gap-1 min-w-0">
<span
class="min-w-0 truncate text-xs {emptyString(summary)
@@ -2392,6 +2392,7 @@
section={{ label: mode === 'edit' ? 'Pipeline editor' : 'Pipeline' }}
afterName={pipelineFolder}
actions={pipelineActions}
actionsFlexible
/>
{#snippet pipelineFolder()}
@@ -2437,154 +2438,159 @@
{/snippet}
{#snippet pipelineActions()}
{#if !isOperator}
<!-- Center group: the mode toggle is the page's primary control —
anchored between the two flex-1 side groups so it stays
centered, with breathing room on both sides. -->
<div class="flex flex-row items-center gap-2 shrink-0 px-6">
<PipelineModeToggle {mode} draftCount={pe.drafts.size} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pe.drafts.size > 0}
<!-- View variant: overlay the unsaved drafts onto the deployed
<!-- This page carries more actions than any other in the band, so the set scrolls rather than
being pushed off the end — the same escape the row this replaced had. `actionsFlexible` on
the registration is what lets this box shrink far enough for that to matter. -->
<div
class="flex flex-row items-center gap-2 min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap"
>
{#if !isOperator}
<!-- The mode toggle is the page's primary control, so it leads the set. -->
<div class="flex flex-row items-center gap-2 shrink-0 pr-4">
<PipelineModeToggle {mode} draftCount={pe.drafts.size} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pe.drafts.size > 0}
<!-- View variant: overlay the unsaved drafts onto the deployed
graph — "what View will show once they're deployed". -->
<button
type="button"
onclick={() => (includeDrafts = !includeDrafts)}
class={twMerge(
'flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors',
includeDrafts
? 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400'
: 'bg-surface border-gray-300 dark:border-gray-600 text-secondary hover:bg-surface-hover'
)}
title={includeDrafts
? 'Showing undeployed drafts overlaid on the deployed pipeline — click to hide them'
: 'Overlay your undeployed drafts to see what the pipeline will look like once deployed'}
>
<Telescope size={14} />
{includeDrafts ? 'Showing' : 'Show'}
{pe.drafts.size} draft{pe.drafts.size === 1 ? '' : 's'}
</button>
{/if}
</div>
{/if}
{#if !isOperator && allPipelineScripts.length > 0}
<!-- Only the armed state surfaces on the bar (arming lives in the ⋮
menu) — a compact disarm pill. -->
{#if recordingMode}
<button
type="button"
onclick={() => (includeDrafts = !includeDrafts)}
class={twMerge(
'flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors',
includeDrafts
? 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400'
: 'bg-surface border-gray-300 dark:border-gray-600 text-secondary hover:bg-surface-hover'
)}
title={includeDrafts
? 'Showing undeployed drafts overlaid on the deployed pipeline — click to hide them'
: 'Overlay your undeployed drafts to see what the pipeline will look like once deployed'}
onclick={() => (recordingMode = false)}
disabled={!!cascadeRunningRoot}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors disabled:opacity-50 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-700 dark:text-red-400"
title={RECORDING_ARMED_HINT}
>
<Telescope size={14} />
{includeDrafts ? 'Showing' : 'Show'}
{pe.drafts.size} draft{pe.drafts.size === 1 ? '' : 's'}
<Circle size={12} class="fill-red-600 text-red-600 animate-pulse" />
Recording
</button>
{/if}
</div>
{/if}
{#if !isOperator && allPipelineScripts.length > 0}
<!-- Only the armed state surfaces on the bar (arming lives in the ⋮
menu) — a compact disarm pill. -->
{#if recordingMode}
<button
type="button"
onclick={() => (recordingMode = false)}
disabled={!!cascadeRunningRoot}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors disabled:opacity-50 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-700 dark:text-red-400"
title={RECORDING_ARMED_HINT}
>
<Circle size={12} class="fill-red-600 text-red-600 animate-pulse" />
Recording
</button>
{/if}
<!-- Pipeline-level run: always visible in both View and Edit so a
<!-- Pipeline-level run: always visible in both View and Edit so a
run never requires hunting for a specific node's play button
(dbt `build` / Dagster "Materialize all"). Runs every script in
dependency order (roots first, cascading downstream) over the
displayed graph — deployed in View, draft-overlaid in Edit. -->
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{
icon: cascadeRunningRoot ? Loader2 : Play,
classes: cascadeRunningRoot ? 'animate-spin' : undefined
}}
onclick={runWholePipeline}
disabled={!!cascadeRunningRoot}
title={cascadeRunningRoot
? 'A pipeline run is already in progress'
: `Run all ${allPipelineScripts.length} script${
allPipelineScripts.length === 1 ? '' : 's'
} in dependency order (roots first, cascading downstream)`}
>
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
</Button>
{/if}
{#if mode === 'edit' && saveErrors.size > 0}
<!-- Compact errors popover anchored next to Save all so users
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{
icon: cascadeRunningRoot ? Loader2 : Play,
classes: cascadeRunningRoot ? 'animate-spin' : undefined
}}
onclick={runWholePipeline}
disabled={!!cascadeRunningRoot}
title={cascadeRunningRoot
? 'A pipeline run is already in progress'
: `Run all ${allPipelineScripts.length} script${
allPipelineScripts.length === 1 ? '' : 's'
} in dependency order (roots first, cascading downstream)`}
>
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
</Button>
{/if}
{#if mode === 'edit' && saveErrors.size > 0}
<!-- Compact errors popover anchored next to Save all so users
can see exactly which drafts failed and why without losing
the editor context. Drafts that succeed disappear from
the map; the ones still listed here are the unresolved
failures. -->
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
{#snippet trigger()}
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
title="View save errors"
>
<AlertTriangle size={14} />
<span>{saveErrors.size} failed</span>
</button>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis">Save errors</span>
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
{#each [...saveErrors.entries()] as [path, message]}
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
<span class="text-2xs font-mono text-emphasis">{path}</span>
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
{message}
</span>
</div>
{/each}
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
{#snippet trigger()}
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
title="View save errors"
>
<AlertTriangle size={14} />
<span>{saveErrors.size} failed</span>
</button>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis">Save errors</span>
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
{#each [...saveErrors.entries()] as [path, message]}
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
<span class="text-2xs font-mono text-emphasis">{path}</span>
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
{message}
</span>
</div>
{/each}
</div>
</div>
</div>
{/snippet}
</Popover>
{/if}
{#if mode === 'edit' && pe.drafts.size > 0}
<!-- Draft autosave status for the whole pipeline bundle. Distinct
{/snippet}
</Popover>
{/if}
{#if mode === 'edit' && pe.drafts.size > 0}
<!-- Draft autosave status for the whole pipeline bundle. Distinct
from "Save all", which DEPLOYS the drafts — this only reflects
that in-flight edits are persisted to the per-user server draft. -->
{#if $workspaceStore}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind={PIPELINE_DRAFT_KIND}
path={pipelineDraftPath}
draftOnly
loadedFromDraft={pe.loadedFromDbDraft}
/>
{#if $workspaceStore}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind={PIPELINE_DRAFT_KIND}
path={pipelineDraftPath}
draftOnly
loadedFromDraft={pe.loadedFromDbDraft}
/>
{/if}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: savingAll ? Loader2 : Save }}
onclick={saveAllDrafts}
disabled={savingAll}
title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`}
>
{savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`}
</Button>
{/if}
{#if mode === 'view'}
<Button
variant={activityShowing ? 'accent-secondary' : 'subtle'}
unifiedSize="sm"
startIcon={{ icon: History }}
onclick={toggleActivity}
title={activityShowing
? 'Hide the activity panel'
: 'Show the pipeline activity feed (recent and live runs)'}
>
Activity
</Button>
{/if}
<Button
variant="accent"
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: savingAll ? Loader2 : Save }}
onclick={saveAllDrafts}
disabled={savingAll}
title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`}
>
{savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`}
</Button>
{/if}
{#if mode === 'view'}
<Button
variant={activityShowing ? 'accent-secondary' : 'subtle'}
unifiedSize="sm"
startIcon={{ icon: History }}
onclick={toggleActivity}
title={activityShowing
? 'Hide the activity panel'
: 'Show the pipeline activity feed (recent and live runs)'}
>
Activity
</Button>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
<DropdownV2 size="sm" items={overflowMenuItems} />
startIcon={{ icon: RefreshCw }}
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
<DropdownV2 size="sm" items={overflowMenuItems} />
</div>
{/snippet}
<div class="flex flex-col h-full">