From 1bc53e38772d45f95926047933caee4cc5aedd93 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 6 Oct 2026 09:30:35 -0700 Subject: [PATCH] feat: the name is what opens its rename editor, and the trail truncates from its start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/FlowBuilder.svelte | 63 +++- .../src/lib/components/NavBreadcrumb.svelte | 209 ++++++++---- .../src/lib/components/PageHeaderBar.svelte | 81 +++-- .../src/lib/components/PathEditPopover.svelte | 66 +++- .../src/lib/components/ScriptBuilder.svelte | 125 ++++--- .../lib/components/SummaryPathDisplay.svelte | 314 ++++++++++-------- .../apps/editor/AppEditorHeader.svelte | 49 ++- .../details/DetailPageHeader.svelte | 21 +- .../flows/content/AgentEditorModal.svelte | 71 +++- .../components/pageHeaderRegistry.svelte.ts | 33 +- .../raw_apps/RawAppEditorHeader.svelte | 48 ++- 11 files changed, 717 insertions(+), 363 deletions(-) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6c55398bb5..3929490d89 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -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(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. --> {/if} + + {#snippet flowPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if flowStore?.val} + + {/if} + {/snippet} + {#snippet flowSummary()} - -
- {emptyString(flowStore.val.summary) ? 'Add a summary...' : flowStore.val.summary} + +
+ {#snippet summaryText()} + {emptyString(flowStore.val.summary) + ? 'Add a summary...' + : flowStore.val.summary} + {/snippet} {#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false} + {:else} + {@render summaryText()} {/if}
{/snippet} diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 496a5c2ca4..d88b6101d4 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -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. --> + open, setOpen} > {#snippet trigger()} - + {#if label} + + {@render label()} + {:else} + + {/if} {/snippet} {#snippet content()}
+ name lives. --> {#if summaryEditable}
{/if} + +{#snippet appPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + {#snippet appSummary()} - -
- {emptyString($summary) ? 'Add a summary...' : $summary} + +
+ {#snippet summaryText()} + {emptyString($summary) ? 'Add a summary...' : $summary} + {/snippet} {/snippet} + +{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + {#snippet badges(size: 'sm' | 'md')} @@ -252,7 +269,7 @@ | 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(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. --> + {#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if draft?.state} + 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()} - -
+
{#if draft?.state} - {emptyString(draft.state.description) - ? 'Add a summary...' - : draft.state.description} + {#snippet summaryText()} + {emptyString(draft.state?.description) + ? 'Add a summary...' + : draft.state?.description} + {/snippet} draft.state?.description ?? '', (v) => { diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 03a004769e..e499c77025 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -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 }[] { 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 }] : [] }) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index b3ceb4c565..c8e9106943 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -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(undefined) @@ -947,10 +947,11 @@ // The path being edited, not the stored one: a brand-new app is parked at a // `draft_` 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} + +{#snippet rawAppPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + {#snippet appSummary()} - -
- {emptyString(summary) ? 'Add a summary...' : summary} + +
+ {#snippet summaryText()} + {emptyString(summary) ? 'Add a summary...' : summary} + {/snippet}