fix: a page keeps its path segment when it cannot offer a rename

Four things the local reviews caught in the rename work:

A detail page handed the band a `pathTrigger` whether or not it could save one.
Without `onSaved` — every script, flow or agent page the reader cannot write —
`SummaryPathDisplay` fell through to its read-only branch, which ignores the
`label` it was given and draws its own block: the trail lost its path and the
summary appeared twice, two lines deep, in a 44px bar. The page now offers the
trigger only where a rename can be saved, and the component renders what it was
handed rather than its own row.

The band gives up its own segment the moment a page offers a `pathTrigger`, so a
trigger whose own gate is closed left the trail with no path at all — for the
whole of an agent draft's load, not just a flush. Each gated snippet now draws
the path itself in that case.

`openFocus` named the path field as `#path`, which melt resolves with a
document-wide query; `Path` hardcodes that id at every call site, and the
popover is portalled to the body, so it lost document order to any other `Path`
on the page. It is scoped to the popover's own field now.

And `open` was an exported `$bindable(false)` on an optional prop, which the
frontend's own rules ban. No caller bound it, so it is local state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-06 09:56:13 -07:00
co-authored by Claude Opus 5
parent 1bc53e3877
commit d0e14b05e1
7 changed files with 96 additions and 59 deletions
@@ -1667,8 +1667,9 @@
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. -->
<!-- Gated the way the script editor gates its own, `{:else}` included: this renders from
the band's tree, which outlives what it reads here by a flush, and the band has
already given up its own segment by the time this decides. -->
{#if flowStore?.val}
<PathEditPopover
label={pathLabel}
@@ -1684,6 +1685,8 @@
onBehalfOfEmail={$savedOnBehalfOfEmail}
workspaceId={autosaveWorkspace}
/>
{:else}
<span class={triggerClass}>{@render pathLabel()}</span>
{/if}
{/snippet}
@@ -92,8 +92,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba
// 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',
// 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.
@@ -132,14 +132,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
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.
where the user is.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to. -->
<div class="flex 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. -->
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
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
@@ -165,9 +165,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<!-- A dot rather than a slash: the summary names the same item the path just located,
it is not another level of it. Inside this box so that a bar wrapping on a phone
carries it down with the summary instead of stranding it on the line above. -->
<!-- A dot rather than a slash: the summary names the same item the path just located, it
is not another level of it. Inside this box so that it narrows with the summary. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
<svelte:boundary onerror={(e) => console.error('page header: summary failed to render', e)}>
@@ -59,8 +59,6 @@ follow.
* 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
@@ -84,7 +82,6 @@ follow.
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()
@@ -98,10 +95,20 @@ follow.
: 'Edit path'
)
// The popover's own state: a host opens this editor by clicking the name it handed over, so
// there is nothing for one to drive from outside.
let open = $state(false)
// 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(pathEditable && (!path || isOwner(path, actingUser, $operatingWorkspace)))
/** The path's name field inside this popover. `Path` hardcodes `id="path"`, and melt resolves a
* string `openFocus` with a document-wide `querySelector`, so a popover portalled to the body
* would hand the keystrokes to whichever other `Path` the page happens to have mounted. */
const pathField = () =>
document.querySelector<HTMLInputElement>('[data-path-edit-path] #path') ?? null
function setOpen(v: boolean) {
open = v
// Only snapshot when there is a path to freeze. A new flow or script opens with `path === ''`
@@ -111,8 +118,8 @@ follow.
}
</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. -->
<!-- `openFocus`: without naming the path's field 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"
@@ -123,7 +130,7 @@ follow.
disableFocusTrap
closeOnOtherPopoverOpen
openFocus={focusField === 'path'
? '#path'
? pathField
: summaryEditable
? '[data-path-edit-summary]'
: undefined}
@@ -178,19 +185,21 @@ follow.
would make a rename to a taken path look untaken the moment the popover is
reopened on it. With no saved path there is nothing the item occupies, so every
path is checked. -->
<Path
autofocus
bind:path
bind:error
initialPath={savedPath ?? path ?? ''}
allowedExistingPath={savedPath}
checkInitialPathExistence={savedPath == undefined}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
workspaceOverride={workspaceId}
/>
<div data-path-edit-path>
<Path
autofocus
bind:path
bind:error
initialPath={savedPath ?? path ?? ''}
allowedExistingPath={savedPath}
checkInitialPathExistence={savedPath == undefined}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
workspaceOverride={workspaceId}
/>
</div>
{#if savedPath && path && path !== savedPath}
<Alert
type="info"
@@ -2392,9 +2392,9 @@
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. -->
the same flush that drops this editor, and this renders from the band's tree. The
`{:else}` is not decoration: the band hands the segment over the moment a page offers
a `pathTrigger`, so a gate that drew nothing would take the path off the trail. -->
{#if script}
<PathEditPopover
label={pathLabel}
@@ -2409,6 +2409,8 @@
kind="script"
workspaceId={autosaveWorkspace}
/>
{:else}
<span class={triggerClass}>{@render pathLabel()}</span>
{/if}
{/snippet}
@@ -87,6 +87,12 @@
}
})
/** The path's name field inside this popover. `Path` hardcodes `id="path"`, and melt resolves a
* string `openFocus` with a document-wide `querySelector`, so a popover portalled to the body
* would hand the keystrokes to whichever other `Path` the page happens to have mounted. */
const pathField = () =>
document.querySelector<HTMLInputElement>('[data-path-edit-path] #path') ?? null
async function save(close: () => void) {
const initialPath = path ?? ''
const newPath = own ? editPath : initialPath
@@ -111,8 +117,8 @@
</script>
{#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. -->
<!-- Without naming the path's own field 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"
@@ -122,7 +128,7 @@
excludeSelectors=".drawer"
disableFocusTrap
openFocus={focusField === 'path' && own
? '#path'
? pathField
: () => {
summaryInput?.focus()
return null
@@ -182,16 +188,18 @@
{/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}
/>
<div data-path-edit-path>
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
</div>
{: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>
@@ -230,16 +238,18 @@
</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 data-path-edit-path>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
</div>
</div>
{/if}
</div>
@@ -279,6 +289,10 @@
<InheritedLabels labels={inheritedLabels} />
</div>
</div>
{:else if label}
<!-- Nothing to open, but the host still drew this into a slot of its own: give back what it
handed over rather than this component's own block, which belongs in a row it owns. -->
{@render label()}
{:else}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
@@ -269,7 +269,14 @@
<!-- 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, pathTrigger }}
item={{
kind: errorHandlerKind,
path,
summaryContent,
// Only where this page can save one: without `onSaved` the editor behind it has nothing
// to write, and the band keeps its own segment, which copies the path.
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
contexts={headerContexts}
separator="always"
@@ -339,8 +339,9 @@
{#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. -->
throws there rather than where it was written. The `{:else}` carries the whole load of
that wait: the band gave up its own segment to this snippet, so without it the trail
would show no path until the draft arrives. -->
{#if draft?.state}
<PathEditPopover
label={pathLabel}
@@ -366,6 +367,8 @@
pathEditable={!readOnly}
summaryEditable={!readOnly}
/>
{:else}
<span class={triggerClass}>{@render pathLabel()}</span>
{/if}
{/snippet}