feat: the name is what opens its rename editor, and the trail truncates from its start

The pen beside a name is gone. The name itself is the trigger, with a hover fill
behind it where the click opens an editor rather than going anywhere — the path
segment that only copies keeps no fill, which is what tells the two apart. A bar
with a summary field of its own (EditorHeader's condensed pane) still gets the
pen, since it has no name to hang the trigger on.

Clicking either half of a name opens the editor under that half with the cursor
in the matching field. The band draws the path and the page draws the summary, so
one popover cannot anchor under both: a page hands the band a `pathTrigger`
snippet and the band renders its path segment inside it, passing the classes its
own segment wears. Each editor supplies a second PathEditPopover for it, and
`focusField` names the field to focus — `#path` explicitly, since melt otherwise
lands on the row's first button.

Those snippets render from the band's tree, which outlives the values they read
by a flush, so each is gated on the value it reads. Without that an agent page
whose draft had not loaded threw inside the band and left the breadcrumb empty.

The trail now carries two weights: the segment that ends it is what the page is
about and takes `font-medium text-emphasis`, everything before it is a step
quieter. Which segment that is follows the route — the workspace on home (the
fork alone inside a fork), a list page's own name, an item's summary.

And it truncates instead of switching to icons at a breakpoint. The workspace
part yields first, the family name before the fork, then the path's folders, and
the item's own name last. The bar no longer wraps on a phone: a wrapping row
never squeezes anything, it hands the trail a line of its own and leaves it
whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-06 09:30:35 -07:00
co-authored by Claude Opus 5
parent 9a838ff262
commit 1bc53e3877
11 changed files with 717 additions and 363 deletions
+47 -16
View File
@@ -37,7 +37,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { getContext, onDestroy, setContext, untrack } from 'svelte'
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'
@@ -224,7 +224,7 @@
// Top-bar responsive collapse. Measured via bind:clientWidth — we can't
// rely on viewport `md:` because the editor lives inside other panes
// (session pane, drawer, etc.) where the viewport stays wide.
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
@@ -360,7 +360,7 @@
savedValue: savedFlow,
modifiedValue: {
...flowStore.val,
// `$pathStore` is the live-edited path (the pen popover binds it).
// `$pathStore` is the live-edited path (the rename popover binds it).
// `flowStore.val.path` doesn't track those edits, so without this the
// rename wouldn't show up in the diff and the unsaved-changes warning
// wouldn't fire when leaving with a pending rename.
@@ -1578,10 +1578,11 @@
bar's edge would be a second one on top of it. -->
<PageHeaderContent
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'flow',
path: pathSnapshot ?? $pathStore,
summaryContent: flowSummary
summaryContent: flowSummary,
pathTrigger: flowPathTrigger
}}
actions={flowHeaderActions}
contexts={headerContexts}
@@ -1662,20 +1663,48 @@
</div>
{/if}
<!-- The summary's editor again, hung off the band's path segment so it opens under the
path, with the cursor in the path field. `bind:` cannot be spread, so the slots are
written out twice; both instances bind the same ones and only one is ever open. -->
{#snippet flowPathTrigger(pathLabel: Snippet, triggerClass: string)}
<!-- Gated the way the script editor gates its own: this renders from the band's tree,
which outlives what it reads here by a flush. -->
{#if flowStore?.val}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary={flowStore.val.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={$pathStore}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="flow"
onBehalfOfEmail={$savedOnBehalfOfEmail}
workspaceId={autosaveWorkspace}
/>
{/if}
{/snippet}
{#snippet flowSummary()}
<!-- 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)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={flowStore.val.summary}
>{emptyString(flowStore.val.summary) ? 'Add a summary...' : flowStore.val.summary}</span
>
<!-- Not edited in place: clicking the name 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="flex items-center gap-1 min-w-0">
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString(flowStore.val.summary)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={flowStore.val.summary}
>{emptyString(flowStore.val.summary)
? 'Add a summary...'
: flowStore.val.summary}</span
>
{/snippet}
{#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false}
<PathEditPopover
penVisibility={emptyString(flowStore.val.summary) ? 'always' : 'hover'}
label={summaryText}
bind:summary={flowStore.val.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
@@ -1686,6 +1715,8 @@
onBehalfOfEmail={$savedOnBehalfOfEmail}
workspaceId={autosaveWorkspace}
/>
{:else}
{@render summaryText()}
{/if}
</div>
{/snippet}
+141 -68
View File
@@ -3,8 +3,13 @@
The page header's breadcrumb: workspace / fork / kind / path, in one flat line.
Every segment is a trigger for the picker that changes it — the workspace menu, the fork family
picker, and the item drill picker for the kind and each path level — and every segment wears the
same weight, size and icon size, so the line reads as one control rather than four.
picker, and the item drill picker for the kind and each path level — and all of them share one
size and one icon size, so the line reads as one control rather than four.
Two weights, not one: the segment that ends the trail is what the page is about and carries the
weight, and everything leading to it is a step quieter. Which segment that is depends on the
route — the workspace on home, a list page's own name, an item's summary (which the band draws,
not this component) — so the lead can run the whole line.
-->
<script lang="ts">
import { Building, ChevronDown, Folder, GitFork, User } from 'lucide-svelte'
@@ -21,7 +26,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
import { twMerge } from 'tailwind-merge'
import { Badge } from '$lib/components/common'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from './DevWorkspaceBadge.svelte'
import { page } from '$app/state'
import { navPageFor } from './sidebar/navPages'
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
@@ -44,7 +49,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
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 workspace part drops its names and keeps its marks. */
/** 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
/** 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
@@ -69,6 +75,10 @@ same weight, size and icon size, so the line reads as one control rather than fo
/** One look for every segment, whichever picker it opens. */
const SEGMENT =
'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors'
/** The trail up to the last segment: where the thing lives, which the reader scans past. */
const LEAD = 'font-normal text-secondary'
/** The last segment: what the page is about, and the only part of the line with weight. */
const LAST = 'font-medium text-emphasis'
const ICON = 14
/** The workspace disc: the same glyph as the rest, with a ring of its own around it. */
const DISC = 20
@@ -110,22 +120,70 @@ same weight, size and icon size, so the line reads as one control rather than fo
const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined)
// A dev workspace wears its environment; the root wears "prod", so which environment the user
// is standing in is answered the same way whichever one it is.
/** Whether this family has a dev workspace anywhere under it. */
const familyHasDev = $derived.by(() => {
const all = $userWorkspaces ?? []
const root = family?.id
return (
!!root && all.some((w) => w.is_dev_workspace && findWorkspaceRoot(w.id, all)?.id === root)
)
})
const envBadge = $derived.by(() => {
if (!inFork) return { text: 'prod', dev: false }
if (currentWs?.is_dev_workspace)
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
// "prod" only where a dev workspace exists to be the other half of the pair: a family with
// one environment gains nothing from naming it.
if (!inFork) return familyHasDev ? 'prod' : undefined
if (currentWs?.is_dev_workspace) return 'dev'
return undefined
})
// Which segment ends the trail, and so carries the weight. Everything before it is `LEAD`: on
// home that is nothing and the workspace itself is the subject, on a list page the page's name
// takes over, and on an item page the summary does — so the workspace, the fork and the path
// all step back to make room for it.
const namedAfterWorkspace = $derived(!!item || !!section || !!routePage)
const workspaceIsLast = $derived(!nameOnly && !namedAfterWorkspace)
// 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)
// 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.
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
//
// Inside a fork the family's name is the lead even where the workspace part ends the trail: the
// workspace the user stands in is the fork, and the family before it says which product that
// fork belongs to. Weighting both would name two workspaces as the subject.
const scopeChipClass = $derived(
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]'
)
)
// 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
// that opens the rename editor keeps the fill, which is what tells the two apart on sight.
const copyPathSegment = $derived(twMerge(pathSegment, 'hover:bg-transparent'))
// The chip keeps its colour wherever it is on the line — that colour is how a fork is told apart
// from its family. Only its weight follows the trail, dropping back once a page's name ends it.
const forkChipClass = $derived(
twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
// A filled chip needs room around what it holds; an uncolored one is just the name and
// would only be pushed away from the slash before it.
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
'px-1.5 py-0.5 bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]',
// Where the workspace part ends the trail, the fork is the segment that ends it, so it
// takes the weight on its own. Without a colour of its own it also has to take the text
// colour, since it would otherwise inherit the lead's from the link around it.
workspaceIsLast ? twMerge('font-semibold', !forkAccent && 'text-emphasis') : 'font-normal'
)
)
@@ -165,14 +223,15 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/snippet}
{#snippet envBadgeMark()}
{#if envBadge}
<Badge
color={envBadge.dev ? 'dark-blue' : 'gray'}
small
class={envBadge.dev
? 'text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white'
: 'text-3xs px-1 py-0'}>{envBadge.text}</Badge
>
{#if envBadge === 'dev'}
<DevWorkspaceBadge
label={currentWs?.dev_workspace_label}
color={currentWs?.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{:else if envBadge === 'prod'}
<Badge color="gray" small class="text-3xs px-1 py-0">prod</Badge>
{/if}
{/snippet}
@@ -191,7 +250,11 @@ same weight, size and icon size, so the line reads as one control rather than fo
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. -->
<div class={narrow ? 'flex items-center shrink-0' : 'flex items-center min-w-0'}>
<!-- `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]">
<!-- 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. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
@@ -202,33 +265,24 @@ same weight, size and icon size, so the line reads as one control rather than fo
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
{#if narrow}
<!-- Short of room, the part keeps what it cannot be read without: the disc in the
workspace's own colour, the fork mark, and the environment. The names go — the
hover title still carries them, and the picker beside it names them all. -->
{@render workspaceDisc()}
{#if showFork}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{: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. 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} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it
is part of, and either alone leaves the other to be guessed. -->
{@render slash('mx-0.5')}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
</span>
{:else}
</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}
</span>
{:else}
{@render envBadgeMark()}
{/if}
</a>
</div>
@@ -251,7 +305,11 @@ same weight, size and icon size, so the line reads as one control rather than fo
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody item={menuItem} closeMenu={() => workspaceMenu?.close()} />
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
@@ -268,21 +326,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
<!-- 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">
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
pointer and the popup after the click are affordance enough. -->
<!-- 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. Past the
cap it truncates; the click still copies the path whole. -->
<button
class={twMerge(
SEGMENT,
'gap-1.5 hover:bg-transparent overflow-hidden',
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
>
<!-- Past the cap the trail truncates; whatever the click does still has the path whole. -->
{#snippet pathLabel()}
{#if scopeKind === 'u'}
{@render userIcon()}
{:else}
@@ -290,14 +335,31 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/if}
{#each pathLevels as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<!-- The last level absorbs the squeeze: the scopes above it are a couple of
characters each, and cutting them first leaves "m… / draft_a5b1…". -->
<!-- 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 shrink-0 max-w-[8rem]'}
>{level}</span
class={i === pathLevels.length - 1
? 'truncate'
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
>
{/each}
</button>
{/snippet}
{#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. -->
{@render item.pathTrigger(pathLabel, pathSegment)}
{:else}
<button
class={copyPathSegment}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
>
{@render pathLabel()}
</button>
{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
<span
@@ -309,7 +371,10 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/if}
</div>
{:else if item.kind}
<span class="{SEGMENT} hover:bg-transparent" aria-current="page">
<span
class={twMerge(SEGMENT, 'hover:bg-transparent', itemIsLast ? LAST : LEAD)}
aria-current="page"
>
{@render itemKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[item.kind]}</span>
</span>
@@ -318,7 +383,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={SEGMENT}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
@@ -327,7 +392,12 @@ same weight, size and icon size, so the line reads as one control rather than fo
{:else if section}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class="{SEGMENT} hover:bg-transparent hover:text-primary {section.content ? '' : 'truncate'}"
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}
@@ -337,7 +407,10 @@ same weight, size and icon size, so the line reads as one control rather than fo
{@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="{SEGMENT} shrink-0 hover:bg-transparent hover:text-primary" aria-current="page">
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
@@ -33,9 +33,9 @@ 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)
// Below this, the bar is short enough that the workspace's name is the first thing worth
// giving up: it is the part of the trail the user changes least and the picker beside it still
// names every workspace.
// 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). */
@@ -86,7 +86,11 @@ The row's height matches the sidebar's own header row, so the two read as one ba
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',
phone ? 'flex-wrap content-center min-h-11 py-1 pl-1 pr-2' : 'h-11 pl-2 pr-4',
// 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 ? 'h-11 pl-1 pr-2' : 'h-11 pl-2 pr-4',
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there — and it cannot wrap, since that surface is as tall as the bar.
content?.actionsFill && 'pr-0 flex-nowrap',
@@ -123,31 +127,38 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</div>
{/if}
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
controls, they are what should narrow, not the name of where the user is. It still gives
way rather than pushing them off the bar once there is nothing left to take.
On a phone it stops yielding at 10rem, and that floor is what makes the bar wrap: the
page's buttons no longer fit beside a trail that wide, so they take the line below
instead of squeezing the name down to an ellipsis.
<!-- 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. Only below that does the bar wrap and give the trail a line of its own.
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={twMerge('flex min-w-0', phone ? 'shrink min-w-[10rem]' : 'shrink-[0.1]')}>
<div class="flex shrink-[0.3] min-w-[5rem]">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out
of the tree that named it, and the pen in there asks that tree who the acting user is
before it offers to rename anything. -->
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{narrow}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
<!-- 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 && (item.summaryContent || item.summary)}
@@ -159,11 +170,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
carries it down with the summary instead of stranding it on the line above. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
<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}
@@ -191,11 +204,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
)}
>
{#each actions as entry, i (i)}
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
<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}
@@ -1,13 +1,15 @@
<!--
@component
The pen that opens an item's summary and path for editing, beside the breadcrumb that shows them.
The editor behind an item's name: clicking the name it is given opens the summary and the path.
A caller that has no name to give it — a bar with a summary field of its own — gets a pen instead.
A caller that draws the path itself should render it from `snapshotPath ?? path`: while the
popover is open this holds the path as it was when it opened, so the trail — and the pen anchored
to it — does not reflow under the pointer as the user types, which floating-ui would follow.
popover is open this holds the path as it was when it opened, so the trail — and the popover
anchored to it — does not reflow under the pointer as the user types, which floating-ui would
follow.
-->
<script lang="ts">
import type { ComponentProps } from 'svelte'
import type { ComponentProps, Snippet } from 'svelte'
import { Alert } from '$lib/components/common'
import PathEditPen from '$lib/components/PathEditPen.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -31,7 +33,7 @@ to it — does not reflow under the pointer as the user types, which floating-ui
/** 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. */
* refuse the other, and the popover 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. */
@@ -44,10 +46,26 @@ to it — does not reflow under the pointer as the user types, which floating-ui
workspaceId?: string
/** When set, warns that a redeploy happens on behalf of the current user instead. */
onBehalfOfEmail?: string | undefined
/** Only read when there is no `label`, where the pen is the whole trigger. */
penVisibility?: 'hover' | 'always'
/** The path as it was when the popover opened, undefined while it is closed. See the
* component note: the caller draws its trail from this so it holds still mid-rename. */
snapshotPath?: string | undefined
/** The item's name, rendered inside the trigger so clicking it opens the editor. Without
* one the trigger is a pen, for a host that draws its name somewhere this cannot reach. */
label?: Snippet
/** The trigger's own classes, for a host that needs it to wear the look of the row it sits
* in — the band's path segment. The default is a name with a hover fill behind it: the
* click opens an editor here rather than going anywhere, and the fill is what says the
* name is a control at all now that no pen sits beside it. */
triggerClass?: string
/** Bindable, for a host that drives the popover from elsewhere. */
open?: boolean
/** Which field takes the cursor on open. A popover anchored under the path opens on the
* path, one under the summary opens on the summary: the click says which half of the name
* the user came to change. `Path` autofocuses itself, so 'path' is the absence of a
* claim on the summary rather than a claim of its own. */
focusField?: 'summary' | 'path'
/** The path field's verdict, for a host that refuses to deploy while it is non-empty.
* A host whose item has a second path field has to bind both to the same slot, or
* whichever one is unmounted leaves its last verdict standing. */
@@ -64,11 +82,14 @@ to it — does not reflow under the pointer as the user types, which floating-ui
workspaceId,
onBehalfOfEmail,
penVisibility = 'hover',
label,
triggerClass = 'inline-flex items-center gap-1.5 min-w-0 max-w-full px-1 py-0.5 rounded cursor-pointer text-left hover:bg-surface-hover transition-colors',
open = $bindable(false),
focusField = 'summary',
snapshotPath = $bindable(),
error = $bindable()
}: Props = $props()
let open = $state(false)
const penLabel = $derived(
summaryEditable && pathEditable
? 'Edit summary and path'
@@ -90,31 +111,56 @@ to it — does not reflow under the pointer as the user types, which floating-ui
}
</script>
<!-- `openFocus`: `#path` is the name field inside `Path`. Without naming it melt lands on the
row's first button, so a popover opened from the path would not be typing into the path. -->
<Popover
placement="bottom-start"
contentClasses="p-4"
class={triggerClass}
triggerAttrs={{ title: penLabel, 'aria-label': penLabel }}
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
openFocus={focusField === 'path'
? '#path'
: summaryEditable
? '[data-path-edit-summary]'
: undefined}
bind:isOpen={() => open, setOpen}
>
{#snippet trigger()}
<PathEditPen label={penLabel} visibility={penVisibility} {open} />
{#if label}
<!-- The name is the affordance; a pen next to it would be a second one for the same
click, and the band keeps its line quiet. -->
{@render label()}
{:else}
<PathEditPen label={penLabel} visibility={penVisibility} {open} />
{/if}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-6 w-[480px]">
<!-- The summary first: it is the name a reader sees, and the path below is where that
name lives. Not autofocused — the pen's own job is the path, which takes the cursor. -->
name lives. -->
{#if summaryEditable}
<Label label="Summary">
<TextInput
bind:value={summary}
inputProps={{
placeholder: 'Add a summary...',
'data-path-edit-summary': '',
// The popover sits in a page that binds keys of its own (the editors all do),
// and a summary is prose — it must not reach them.
onkeydown: (e: KeyboardEvent) => e.stopPropagation(),
// and a summary is prose — it must not reach them. Enter commits the line and
// closes, which is what a one-line field reads as; the path below is reached
// with Tab, not Enter.
onkeydown: (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
summary = summary?.trim()
setOpen(false)
}
},
onblur: () => (summary = summary?.trim())
}}
size="sm"
@@ -93,7 +93,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack, type Snippet } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
@@ -201,7 +201,7 @@
// right group (hide the ~200px tag select, icon-only Diff/Settings) before the
// full group crowds the path into heavy truncation; ~900 is where the path
// keeps a usable width given the right group's natural ~440px.
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
@@ -2291,10 +2291,15 @@
bar's edge would be a second line on top of it. -->
<PageHeaderContent
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'script',
path: pathSnapshot ?? script.path,
summaryContent: scriptSummary
// `script?`: this object is built inside a getter the band evaluates from its own
// tree, and `script` is a bindable prop. Deploying a new script clears the draft the
// 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,
summaryContent: scriptSummary,
pathTrigger: scriptPathTrigger
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
@@ -2345,48 +2350,88 @@
{/if}
{#snippet scriptSummary()}
<!-- 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)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={script.summary}
>{emptyString(script.summary) ? 'Add a summary...' : script.summary}</span
>
{#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}
kind="script"
workspaceId={autosaveWorkspace}
/>
{/if}
</div>
<!-- Everything the band pulls from this editor is gated on `script`. It is a bindable prop,
and these snippets render from the band's tree, where nothing orders the route clearing
that prop against this component's teardown: deploying a new script discards the draft
bound here and drops the `{#if}` around this editor in the same flush. -->
{#if script}
<!-- Not edited in place: clicking the name 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="flex items-center gap-1 min-w-0">
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString(script.summary)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={script.summary}
>{emptyString(script.summary) ? 'Add a summary...' : script.summary}</span
>
{/snippet}
{#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false}
<PathEditPopover
label={summaryText}
bind:summary={script.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="script"
workspaceId={autosaveWorkspace}
/>
{:else}
{@render summaryText()}
{/if}
</div>
{/if}
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment: anchored there it
opens under the path, and `focusField` puts the cursor in the field that half of the
name belongs to. The bindings are written out again because `bind:` cannot be spread;
both instances bind the same slots, and only one is ever open. -->
{#snippet scriptPathTrigger(pathLabel: Snippet, triggerClass: string)}
<!-- Gated like the summary beside it: `script` is a bindable prop the route can clear in
the same flush that drops this editor, and this renders from the band's tree. Without
the trigger the band draws its own path segment, which is what it does for every page
that offers no rename. -->
{#if script}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary={script.summary}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="script"
workspaceId={autosaveWorkspace}
/>
{/if}
{/snippet}
{#snippet scriptMarks()}
<!-- Concurrency, cache, a dedicated worker: settings that hold whatever the editor shows,
so they belong with the script's name. The language icon the editor's own bar carried
does not come along — the code under it says which language this is. -->
{@render settingsBadges('pl-1.5')}
{#if script}
<!-- Concurrency, cache, a dedicated worker: settings that hold whatever the editor shows,
so they belong with the script's name. The language icon the editor's own bar carried
does not come along — the code under it says which language this is. -->
{@render settingsBadges('pl-1.5')}
{/if}
{/snippet}
{#snippet scriptHeaderActions()}
<!-- Saving state and other people's drafts: the editor's own bar carries them beside the
path, so the header has to carry them when it owns the bar — without them a
full-page editor saves with no word either way. -->
{@render autosaveIndicator()}
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{#if script}
<!-- Saving state and other people's drafts: the editor's own bar carries them beside the
path, so the header has to carry them when it owns the bar — without them a
full-page editor saves with no word either way. -->
{@render autosaveIndicator()}
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
{@render rightButtons()}
{/if}
{@render rightButtons()}
{/snippet}
{#snippet languageButton(size: number)}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { emptyString, isOwner } from '$lib/utils'
import { Alert, Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -10,7 +11,6 @@
import Label from './Label.svelte'
import LabelsInput from './LabelsInput.svelte'
import InheritedLabels from './InheritedLabels.svelte'
import PathEditPen from './PathEditPen.svelte'
import Badge from './common/badge/Badge.svelte'
import {
useOperatingUser,
@@ -33,6 +33,14 @@
kind?: 'flow' | 'script' | 'agent'
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
/** Rendered inside the trigger in place of the summary, for a host that hangs a second copy
* of this editor off something else it draws — the band's path segment. */
label?: Snippet
/** The trigger's own classes, for a host whose row has a look of its own. */
triggerClass?: string
/** Which field takes the cursor on open: the click says which half of the name the user came
* to change. `Path` is only reachable for an owner, so 'path' falls back to the summary. */
focusField?: 'summary' | 'path'
}
let {
@@ -43,7 +51,10 @@
editable = false,
onSaved,
kind = 'flow',
compact = false
compact = false,
label,
triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors',
focusField = 'summary'
}: Props = $props()
// An agent lives at a resource path, so that is what the picker validates against; the
@@ -54,11 +65,9 @@
let editPath = $state('')
let dirtyPath = $state(false)
let popoverOpen = $state(false)
let ownPath = $state<string | undefined>(undefined)
// Derived: ownership answers about the operating workspace, whose user resolves asynchronously.
const own = $derived(
ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace)
)
// Resolved before the popover opens, not on opening it: the path field only exists for an owner,
// and `openFocus` looks for it in the same flush the popover renders in.
const own = $derived(isOwner(path ?? '', actingUser, $operatingWorkspace))
let onBehalfOfEmail = $state<string | undefined>(undefined)
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
let labelsDirty = $state(false)
@@ -69,7 +78,6 @@
editSummary = summary ?? ''
editPath = path ?? ''
labelsDirty = false
ownPath = path ?? ''
onBehalfOfEmail = undefined
if (kind === 'flow' && $operatingWorkspace && path) {
checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => {
@@ -102,11 +110,151 @@
}
</script>
{#if editable || onSaved}
<!-- The name reads as text and the pen beside it opens the popover, the same way the editors
carry theirs. The whole block used to be the trigger, so a name you only wanted to read
answered a click — and the pen has to sit outside the popover to be the only thing that
does. -->
{#snippet editor()}
<!-- `#path` is the name field inside `Path`; without naming it melt lands on the row's first
button, so a popover opened from the path would not be typing into the path. -->
<Popover
class={triggerClass}
placement="bottom-start"
contentClasses="p-4"
triggerAttrs={{ title: 'Edit summary and path', 'aria-label': 'Edit summary and path' }}
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={focusField === 'path' && own
? '#path'
: () => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<!-- Both of the popover's arms show the summary and the path, and ownership decides
whether the path can be changed, not whether it is there. -->
{#if label}
{@render label()}
{:else}
<span
class="{compact
? 'text-xs font-medium'
: 'text-sm font-semibold'} block truncate {emptyString(summary)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
{/if}
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-6 w-[480px]">
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
save(close)
}
}
}}
bind:value={editSummary}
/>
</Label>
<div class="-mt-4 flex items-center gap-2">
<LabelsInput
bind:labels
onchange={() => {
labelsDirty = true
}}
/>
{#if inheritedLabels?.length}
<InheritedLabels labels={inheritedLabels} />
{/if}
</div>
{#if inheritedLabels?.length}
<p class="-mt-5 text-2xs text-tertiary">
Gray labels are inherited from the folder and can only be edited there.
</p>
{/if}
<Label label="Path">
{#if own}
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
{:else}
<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>
{/if}
</Label>
{#if onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" size="xs">
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => save(close)}
>
Save
</Button>
{:else}
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
close()
}
}
}}
bind:value={summary}
/>
</label>
<div class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</div>
{/snippet}
</Popover>
{/snippet}
{#if label && (editable || onSaved)}
<!-- The host draws the row this hangs in — the band's path segment — so only the trigger comes
from here: no path line above it, and no second copy of the labels. -->
{@render editor()}
{:else if editable || onSaved}
<!-- The name itself opens the popover, the same way the editors do it. Only the name: the whole
block was the trigger once, which meant a path or a label you only wanted to read answered
a click. -->
<div
class="min-w-0 truncate flex {compact
? 'items-center px-1 py-0.5'
@@ -117,146 +265,18 @@
>{path}</span
>
{/if}
<div class="group flex items-center gap-3 max-w-full min-w-0">
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
summary
)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
<div class="flex items-center gap-3 max-w-full min-w-0">
{@render editor()}
<!-- Outside the trigger: a label is read, not clicked through to a rename. -->
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels as label}
<Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge>
{#each labels as labelText}
<Badge color="blue" verySmall class="px-1" title="Label: {labelText}">{labelText}</Badge
>
{/each}
</div>
{/if}
<InheritedLabels labels={inheritedLabels} />
<Popover
class="shrink-0"
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={() => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<!-- Both of the popover's arms show the summary and the path, and ownership — which
is only resolved once it opens — decides whether the path can be changed, not
whether it is there. -->
<PathEditPen
label="Edit summary and path"
visibility={emptyString(summary) ? 'always' : 'hover'}
open={popoverOpen}
/>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-6 w-[480px]">
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
save(close)
}
}
}}
bind:value={editSummary}
/>
</Label>
<div class="-mt-4 flex items-center gap-2">
<LabelsInput
bind:labels
onchange={() => {
labelsDirty = true
}}
/>
{#if inheritedLabels?.length}
<InheritedLabels labels={inheritedLabels} />
{/if}
</div>
{#if inheritedLabels?.length}
<p class="-mt-5 text-2xs text-tertiary">
Gray labels are inherited from the folder and can only be edited there.
</p>
{/if}
<Label label="Path">
{#if own}
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
{:else}
<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>
{/if}
</Label>
{#if onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" size="xs">
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => save(close)}
>
Save
</Button>
{:else}
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
close()
}
}
}}
bind:value={summary}
/>
</label>
<div class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</div>
{/snippet}
</Popover>
</div>
</div>
{:else}
@@ -32,7 +32,7 @@
Zap,
Globe
} from 'lucide-svelte'
import { getContext, onDestroy, untrack } from 'svelte'
import { getContext, onDestroy, untrack, type Snippet } from 'svelte'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
import type { App, AppEditorContext, AppViewerContext } from '../types'
import { toStatic } from '../utils'
@@ -151,7 +151,7 @@
ownsPageHeader = false
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
/** Mirror of the path the user is editing in the rename popover. Initialized
* once from `newPath` (or a synthesized path for new apps) and only
* updated by user input from then on — we deliberately do NOT sync from
* `newPath` afterwards so the user's in-flight rename isn't clobbered by
@@ -232,7 +232,7 @@
// The header's buttons render under the page header, not under this component, so the contexts
// they look up have to travel with them. The app's own two carry everything the canvas toggles,
// the panel buttons, Debug runs and the preview switch read.
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
@@ -998,10 +998,11 @@
// The path being edited, not the stored one: a brand-new app is parked at a
// `draft_<uuid>` placeholder, and the trail would name that instead of the path Deploy
// will create. Same fallback chain the rename sites read.
// Frozen while the pen's popover is open so the trail holds still as the user types.
// Frozen while the rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined),
summaryContent: appSummary
summaryContent: appSummary,
pathTrigger: appPathTrigger
}}
actions={appHeaderActions}
contexts={headerContexts}
@@ -1037,18 +1038,36 @@
</div>
{/if}
<!-- The summary's editor again, hung off the band's path segment so it opens under the path, with
the cursor in the path field. `bind:` cannot be spread, so the slots are written out twice;
both instances bind the same ones and only one is ever open. -->
{#snippet appPathTrigger(pathLabel: Snippet, triggerClass: string)}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary={$summary}
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}
savedPath={$appPath || newPath || undefined}
kind="app"
/>
{/snippet}
{#snippet appSummary()}
<!-- 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)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={$summary}>{emptyString($summary) ? 'Add a summary...' : $summary}</span
>
<!-- Not edited in place: clicking the name 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="flex items-center gap-1 min-w-0">
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString($summary)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={$summary}>{emptyString($summary) ? 'Add a summary...' : $summary}</span
>
{/snippet}
<PathEditPopover
penVisibility={emptyString($summary) ? 'always' : 'hover'}
label={summaryText}
bind:summary={$summary}
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}
@@ -3,7 +3,7 @@
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { createEventDispatcher, getContext, tick } from 'svelte'
import { createEventDispatcher, getContext, tick, type Snippet } from 'svelte'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
@@ -187,6 +187,23 @@
/>
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
with the cursor in the path field. `bind:` cannot be spread, so `labels` is bound twice; both
instances bind the same slot and only one is ever open. -->
{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)}
<SummaryPathDisplay
label={pathLabel}
{triggerClass}
focusField="path"
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
/>
{/snippet}
<!-- The two halves of the row, rendered side by side in the band and in the two groups of the
row below. One copy each: a button added to one placement belongs in both. -->
{#snippet badges(size: 'sm' | 'md')}
@@ -252,7 +269,7 @@
<!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in
the breadcrumb: renaming a script or flow is done from this page, not from the trail. -->
<PageHeaderContent
item={{ kind: errorHandlerKind, path, summaryContent }}
item={{ kind: errorHandlerKind, path, summaryContent, pathTrigger }}
{actions}
contexts={headerContexts}
separator="always"
@@ -2,7 +2,7 @@
import { FlaskConical, Settings, FormInput, History, MessageSquare, Save } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { onDestroy, untrack } from 'svelte'
import { onDestroy, untrack, type Snippet } from 'svelte'
import { resource } from 'runed'
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
@@ -89,7 +89,7 @@
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let settingsDrawer: Drawer | undefined = $state(undefined)
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
@@ -316,7 +316,11 @@
breadcrumb, the level it is on reads after that name, and its controls are the band's
actions. -->
<PageHeaderContent
item={{ path: pathSnapshot ?? shownPath, summaryContent: agentSummary }}
item={{
path: pathSnapshot ?? shownPath,
summaryContent: agentSummary,
pathTrigger: agentPathTrigger
}}
afterName={agentHint}
actions={settings}
separator="always"
@@ -329,23 +333,62 @@
{/if}
{/key}
<!-- The summary's editor again, hung off the band's path segment so it opens under the path,
with the cursor in the path field. `bind:` cannot be spread, so the slots are written out
twice; both instances bind the same ones and only one is ever open. -->
{#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)}
<!-- Gated like the summary beside it: this renders from the band's tree, where `draft` is
undefined for as long as the modal has not loaded one, and a getter that reads through it
throws there rather than where it was written. Without the trigger the band draws its own
path segment, which is what it does for every page that offers no rename. -->
{#if draft?.state}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary={
() => draft.state?.description ?? '',
(v) => {
if (draft.state) draft.state.description = v
}
}
bind:path={
() => draft.state?.path ?? '',
(v) => {
if (draft.state) draft.state.path = v
}
}
bind:snapshotPath={pathSnapshot}
bind:error={() => host?.pathError(), (error) => host?.setPathError(error)}
savedPath={draft?.noDeployed ? undefined : target?.path}
kind="resource"
workspaceId={ws}
pathEditable={!readOnly}
summaryEditable={!readOnly}
/>
{/if}
{/snippet}
{#snippet agentSummary()}
<!-- What the agent is, beside what it is called, and the pen that renames both — the same
<!-- What the agent is, beside what it is called, and clicking it renames both — the same
shape the script, flow and app editors carry. The version rides along, as the
linked-agent card in the step panel has it. The trail's levels are not here: this layout
opens settings in a drawer and evals over the page, so it never stands on one. -->
<div class="group flex items-center gap-1 min-w-0">
<div class="flex items-center gap-1 min-w-0">
{#if draft?.state}
<span
class="min-w-0 truncate text-xs {emptyString(draft.state.description)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={draft.state.description}
>{emptyString(draft.state.description)
? 'Add a summary...'
: draft.state.description}</span
>
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString(draft.state?.description)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={draft.state?.description}
>{emptyString(draft.state?.description)
? 'Add a summary...'
: draft.state?.description}</span
>
{/snippet}
<PathEditPopover
label={summaryText}
bind:summary={
() => draft.state?.description ?? '',
(v) => {
@@ -6,8 +6,14 @@ export type PageHeaderItem = {
/** Absent for an item the picker does not file under a kind — an agent's detail page. The
* breadcrumb then names the item without claiming it is one of the three. */
kind?: WorkspaceItemKind
/** Shown whole, and copied on click. */
/** Shown whole. Clicking it opens `onEditName` where a page offers one, and copies otherwise. */
path: string | undefined
/** Wraps the band's own path segment in whatever the page wants behind it — its rename
* popover, anchored there rather than beside the summary, so the editor opens under the half
* of the name that was clicked. The band passes the path as it renders it and the classes its
* own segment wears, so the page's control keeps the breadcrumb's look rather than inventing
* one. Without this the segment copies the path on click. */
pathTrigger?: Snippet<[Snippet, string]>
summary?: string
/** Rendered in place of the plain summary, for a page whose summary is itself a control —
* a detail page's rename-and-labels popover, an editor's editable title. */
@@ -93,6 +99,24 @@ export const PHONE_BAR = 800
// which the sidebar and a page's side panel both take from.
let barWidth = $state(0)
/**
* One registration's content, or undefined while it cannot answer.
*
* An entry is a getter closing over another component's props, evaluated here, in a tree of its
* own. Nothing orders a parent clearing those props against the child's teardown: a route that
* drops the `{#if}` around an editor and the value it binds into it does both in one flush, and
* this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The
* entry has nothing to say in that window, which is not the same as the page having no header —
* so it is skipped, and whatever registered before it still stands.
*/
function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined {
try {
return e.get()
} catch {
return undefined
}
}
export const pageHeader = {
/**
* The registrations merged, later ones winning field by field: a route sets the frame (where
@@ -103,7 +127,8 @@ export const pageHeader = {
if (entries.length === 0) return undefined
const merged: PageHeaderContent = {}
for (const e of entries) {
const c = e.get()
const c = read(e)
if (!c) continue
if (c.item !== undefined) merged.item = c.item
if (c.section !== undefined) merged.section = c.section
if (c.afterName !== undefined) merged.afterName = c.afterName
@@ -123,8 +148,8 @@ export const pageHeader = {
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
return entries
.flatMap((e, i) => {
const c = e.get()
return c.actions
const c = read(e)
return c?.actions
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
: []
})
@@ -28,7 +28,7 @@
Undo,
WandSparkles
} from 'lucide-svelte'
import { onDestroy, untrack } from 'svelte'
import { onDestroy, untrack, type Snippet } from 'svelte'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
@@ -340,7 +340,7 @@
// header uses.
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
/** Held by the rename popover while it is open; the band's trail reads it so the path does not
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
@@ -947,10 +947,11 @@
// The path being edited, not the stored one: a brand-new app is parked at a
// `draft_<uuid>` placeholder, and the trail would name that instead of the path Deploy
// will create — which is why `newEditedPath` refuses the placeholder. Frozen while the
// pen's popover is open so the trail holds still as the user types.
// rename popover is open so the trail holds still as the user types.
kind: 'app',
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
summaryContent: appSummary
summaryContent: appSummary,
pathTrigger: rawAppPathTrigger
}}
actions={rawAppHeaderActions}
contexts={headerContexts}
@@ -1027,18 +1028,37 @@
{/if}
{/snippet}
<!-- The summary's editor again, hung off the band's path segment so it opens under the path, with
the cursor in the path field. `bind:` cannot be spread, so the slots are written out twice;
both instances bind the same ones and only one is ever open. -->
{#snippet rawAppPathTrigger(pathLabel: Snippet, triggerClass: string)}
<PathEditPopover
label={pathLabel}
{triggerClass}
focusField="path"
bind:summary
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}
savedPath={appPath || undefined}
kind="app"
workspaceId={autosaveWorkspace}
/>
{/snippet}
{#snippet appSummary()}
<!-- 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)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={summary}>{emptyString(summary) ? 'Add a summary...' : summary}</span
>
<!-- Not edited in place: clicking the name 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="flex items-center gap-1 min-w-0">
{#snippet summaryText()}
<span
class="min-w-0 truncate text-xs {emptyString(summary)
? 'text-tertiary italic font-normal'
: 'font-medium text-emphasis'}"
title={summary}>{emptyString(summary) ? 'Add a summary...' : summary}</span
>
{/snippet}
<PathEditPopover
penVisibility={emptyString(summary) ? 'always' : 'hover'}
label={summaryText}
bind:summary
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}