feat: the editors name their item in the band, and a pen renames it

The band showed each editor's summary as an inline field, which put editing
where the page names what it is working on. The summary is text there now, with
its `title` for one the band truncates, and the pen that opens the path for
editing sits after it — the same popover the editors' own bar carries, at the end
of the name it renames. Every editor already has a field for the summary itself:
the script's settings, the flow's Settings tab, and both app editors' deploy
drawer.

The popover moves into `PathEditPopover` rather than being written out four more
times. It owns the open state and hands back the path as it was when it opened:
the band's trail reads that while the popover is open, so a path growing under
the pointer does not drag the pen — and floating-ui the popover — along with it.
`EditorHeader` renders the same component, so the session pane and the band
cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 01:38:15 -07:00
co-authored by Claude Opus 5
parent 522e5ce096
commit 4f3804cf11
6 changed files with 236 additions and 153 deletions
+15 -94
View File
@@ -1,10 +1,7 @@
<script lang="ts">
import { ChevronRight, Pencil } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import { ChevronRight } from 'lucide-svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Path from '$lib/components/Path.svelte'
import Label from '$lib/components/Label.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import {
dirKey,
KIND_LABEL_LOWER,
@@ -15,17 +12,6 @@
} from '$lib/components/workspacePicker'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { splitItemPath } from '$lib/components/breadcrumbPath'
import { isOwner } from '$lib/utils'
import { userStore } from '$lib/stores'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
summary?: string
path?: string
@@ -76,23 +62,10 @@
workspaceId
}: Props = $props()
let pathPopoverOpen = $state(false)
/** Snapshot of `path` taken when the pen popover opens; cleared on close.
* While set, the breadcrumb derives from it instead of `path` so the pen
* anchor doesn't reflow as the user types in the popover (which would drag
* the popover with it via floating-ui's auto-update). */
/** Held by the pen popover while it is open, so the trail below — and the pen anchored to its
* end — does not reflow as the user types. */
let snapshotPath = $state<string | undefined>(undefined)
function setPathPopoverOpen(open: boolean) {
pathPopoverOpen = open
// Only snapshot when there's a path to freeze. New flows / scripts open
// the popover with `path === ''` and rely on `Path.reset()` to seed a
// path; if we snapshot the empty value the breadcrumb stays blank for
// the whole popover lifetime. Leaving `snapshotPath` undefined lets
// `displayPath` track the live seeded path instead.
snapshotPath = open && path ? path : undefined
}
// Path segments. e.g. "f/demo/weather_report" → scope "f/demo", slug "weather_report".
// `snapshotPath` keeps the breadcrumb frozen while the pen popover is open
// so the trigger doesn't drift mid-edit. Outside of that, the breadcrumb
@@ -101,11 +74,6 @@
let displayPath = $derived(snapshotPath ?? path ?? '')
let segments = $derived(splitItemPath(displayPath) ?? null)
// Treat an empty path as ownable so the pen popover lets a user pick the
// path for a brand-new item. `Path.reset()` then synthesizes a default
// under their own user/folder scope.
let own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace))
// Virtual entry for the picker: surfaces the currently-edited item at its
// live path (which may differ from `savedPath` mid-rename, so the picker
// otherwise has no leaf to expand).
@@ -174,65 +142,18 @@
{/if}
</nav>
<!-- Pen → path-edit popover. Skipped entirely when path editing is
disabled so the user doesn't see an inert button. -->
<!-- Skipped entirely when path editing is disabled so the user doesn't see an inert
button. -->
{#if pathEditable}
<Popover
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
bind:isOpen={() => pathPopoverOpen, setPathPopoverOpen}
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: Pencil }}
title="Edit path"
aria-label="Edit path"
btnClasses={penVisibility === 'hover' && !pathPopoverOpen
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
: ''}
/>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-6 w-[480px]">
{#if own}
<Path
autofocus
bind:path
initialPath={snapshotPath ?? path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
workspaceOverride={workspaceId}
/>
{#if savedPath && path && path !== savedPath}
<Alert
type="info"
size="xs"
title="Deploy the {kind} to make the path change effective."
/>
{/if}
{#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}
{: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>
</Label>
{/if}
</div>
{/snippet}
</Popover>
<PathEditPopover
bind:path
bind:snapshotPath
{savedPath}
{kind}
{workspaceId}
{onBehalfOfEmail}
{penVisibility}
/>
{/if}
</div>
{/if}
+30 -14
View File
@@ -38,6 +38,7 @@
import { getContext, onDestroy, setContext, untrack } 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'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
@@ -70,7 +71,6 @@
import FlowHistory from './flows/FlowHistory.svelte'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import EditableInput from './common/EditableInput.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
@@ -223,6 +223,10 @@
// 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
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full group — the
@@ -1564,7 +1568,12 @@
<!-- The editor's own top bar is the page header on this route: its breadcrumb and
summary are the header's, and its buttons are the header's actions. -->
<PageHeaderContent
item={{ kind: 'flow', path: $pathStore, summaryContent: flowSummary }}
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
kind: 'flow',
path: pathSnapshot ?? $pathStore,
summaryContent: flowSummary
}}
actions={flowHeaderActions}
contexts={headerContexts}
/>
@@ -1644,18 +1653,25 @@
{/if}
{#snippet flowSummary()}
<!-- The summary stays editable where the editor's own bar had it: the breadcrumb
beside it is the path, which this editor renames from its settings tab. -->
<EditableInput
value={flowStore.val.summary ?? ''}
placeholder="Add a summary..."
editable={customUi?.topBar?.editableSummary != false}
commitOnInput
size="sm"
onSave={(v) => (flowStore.val.summary = v)}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
/>
<!-- 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. -->
<div class="group flex items-center gap-1 min-w-0">
<span
class="min-w-0 truncate text-xs font-medium text-emphasis"
title={flowStore.val.summary}>{flowStore.val.summary}</span
>
{#if customUi?.topBar?.editablePath != false}
<PathEditPopover
bind:path={$pathStore}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="flow"
onBehalfOfEmail={$savedOnBehalfOfEmail}
workspaceId={autosaveWorkspace}
/>
{/if}
</div>
{/snippet}
{#snippet flowHeaderActions()}
@@ -0,0 +1,123 @@
<!--
@component
The pen that opens an item's path for editing, beside the breadcrumb that shows it. Renders
nothing when the path is not the caller's to change.
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.
-->
<script lang="ts">
import { Pencil } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Path from '$lib/components/Path.svelte'
import Label from '$lib/components/Label.svelte'
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
import { isOwner } from '$lib/utils'
import { userStore } from '$lib/stores'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
path?: string
/** The item's *saved* path on the server, so the popover can say a rename needs deploying. */
savedPath?: string
kind?: WorkspaceItemKind
/** Workspace the path picker scopes to; defaults to the operating workspace. */
workspaceId?: string
/** When set, warns that a redeploy happens on behalf of the current user instead. */
onBehalfOfEmail?: string | undefined
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
}
let {
path = $bindable(),
savedPath,
kind = 'flow',
workspaceId,
onBehalfOfEmail,
penVisibility = 'hover',
snapshotPath = $bindable()
}: Props = $props()
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(!path || isOwner(path, actingUser, $operatingWorkspace))
function setOpen(v: boolean) {
open = v
// Only snapshot when there is a path to freeze. A new flow or script opens with `path === ''`
// and relies on `Path.reset()` to seed one; snapshotting the empty value would leave the
// trail blank for the popover's whole lifetime.
snapshotPath = v && path ? path : undefined
}
</script>
<Popover
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
bind:isOpen={() => open, setOpen}
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: Pencil }}
title="Edit path"
aria-label="Edit path"
btnClasses={penVisibility === 'hover' && !open
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
: ''}
/>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-6 w-[480px]">
{#if own}
<Path
autofocus
bind:path
initialPath={snapshotPath ?? path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
workspaceOverride={workspaceId}
/>
{#if savedPath && path && path !== savedPath}
<Alert
type="info"
size="xs"
title="Deploy the {kind} to make the path change effective."
/>
{/if}
{#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}
{: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>
</Label>
{/if}
</div>
{/snippet}
</Popover>
@@ -94,8 +94,8 @@
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import EditableInput from './common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
@@ -200,6 +200,10 @@
// 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
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full button group is
@@ -2280,7 +2284,12 @@
<!-- The editor's own top bar is the page header on this route: the script's path and
summary are the breadcrumb's, and its buttons are the header's actions. -->
<PageHeaderContent
item={{ kind: 'script', path: script.path, summaryContent: scriptSummary }}
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
kind: 'script',
path: pathSnapshot ?? script.path,
summaryContent: scriptSummary
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
/>
@@ -2329,18 +2338,23 @@
{/if}
{#snippet scriptSummary()}
<!-- The summary stays editable where the editor's own bar had it; the path beside it is
the breadcrumb, which this editor renames from its settings drawer. -->
<EditableInput
value={script.summary ?? ''}
placeholder="Add a summary..."
editable={customUi?.topBar?.editableSummary != false}
commitOnInput
size="sm"
onSave={(v) => (script.summary = v.trim())}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
/>
<!-- 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. -->
<div class="group flex items-center gap-1 min-w-0">
<span class="min-w-0 truncate text-xs font-medium text-emphasis" title={script.summary}
>{script.summary}</span
>
{#if customUi?.topBar?.editablePath != false}
<PathEditPopover
bind:path={script.path}
bind:snapshotPath={pathSnapshot}
savedPath={initialPath}
kind="script"
workspaceId={autosaveWorkspace}
/>
{/if}
</div>
{/snippet}
{#snippet scriptMarks()}
@@ -52,8 +52,8 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
@@ -232,6 +232,10 @@
// 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
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
const headerContexts = new Map<any, any>([
['AppViewerContext', getContext('AppViewerContext')],
['AppEditorContext', getContext('AppEditorContext')]
@@ -947,8 +951,9 @@
// 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.
kind: 'app',
path: newEditedPath || $appPath || newPath || undefined,
path: pathSnapshot ?? newEditedPath ?? $appPath ?? newPath ?? undefined,
summaryContent: appSummary
}}
actions={appHeaderActions}
@@ -985,18 +990,18 @@
{/if}
{#snippet appSummary()}
<!-- The summary stays editable where the editor's own bar had it; the path beside it is the
breadcrumb, which this editor renames from its deploy drawer. `title` so a summary the
band truncates can still be read, the way the editor's own bar did it. -->
<div class="max-w-full min-w-0" title={$summary}>
<EditableInput
value={$summary ?? ''}
placeholder="Add a summary..."
commitOnInput
size="sm"
onSave={(v) => ($summary = v.trim())}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
<!-- 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. -->
<div class="group flex items-center gap-1 min-w-0">
<span class="min-w-0 truncate text-xs font-medium text-emphasis" title={$summary}
>{$summary}</span
>
<PathEditPopover
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}
savedPath={$appPath || newPath || undefined}
kind="app"
/>
</div>
{/snippet}
@@ -82,7 +82,7 @@
} from '$lib/components/operatingWorkspace.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
@@ -336,6 +336,10 @@
// 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
* reflow under the pointer as the user types. */
let pathSnapshot = $state<string | undefined>(undefined)
// The actions render under the band, outside the subtree RawAppEditor declared the operating
// workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is.
const headerContexts = new Map<any, any>([operatingWorkspaceContextEntry()])
@@ -931,15 +935,15 @@
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the app's path and summary are
the breadcrumb's, and everything else the bar carried rides along as the header's actions.
The path is a plain label there — this editor renames from its deploy drawer. -->
the breadcrumb's, and everything else the bar carried rides along as the header's actions. -->
<PageHeaderContent
item={{
// 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.
// 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.
kind: 'app',
path: newEditedPath || appPath || newPath || undefined,
path: pathSnapshot ?? newEditedPath ?? appPath ?? newPath ?? undefined,
summaryContent: appSummary
}}
actions={rawAppHeaderActions}
@@ -1017,18 +1021,18 @@
{/snippet}
{#snippet appSummary()}
<!-- The summary stays editable where the editor's own bar had it; the path beside it is the
breadcrumb, which this editor renames from its deploy drawer. `title` so a summary the
band truncates can still be read, the way the editor's own bar did it. -->
<div class="max-w-full min-w-0" title={summary}>
<EditableInput
value={summary ?? ''}
placeholder="Add a summary..."
commitOnInput
size="sm"
onSave={(v) => (summary = v.trim())}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
<!-- 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. -->
<div class="group flex items-center gap-1 min-w-0">
<span class="min-w-0 truncate text-xs font-medium text-emphasis" title={summary}>{summary}</span
>
<PathEditPopover
bind:path={newEditedPath}
bind:snapshotPath={pathSnapshot}
savedPath={appPath || undefined}
kind="app"
workspaceId={autosaveWorkspace}
/>
</div>
{/snippet}