feat: assets-only nodes name their script in a header tab instead of a side chip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 19:05:42 +02:00
co-authored by Claude Opus 5.5
parent 1b159d91f5
commit cf8c0e4257
5 changed files with 132 additions and 99 deletions
@@ -22,7 +22,11 @@
import AssetGraphEdge from './AssetGraphEdge.svelte'
import PanToNode from './PanToNode.svelte'
import InitialFitView from './InitialFitView.svelte'
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
import {
layoutAssetGraph,
PIPELINE_NODE_EXTRA_ROW,
PIPELINE_NODE_HEADER
} from './assetGraphLayout'
import {
assetsOnlyView,
upstreamDeletion,
@@ -1430,12 +1434,17 @@
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
runState: 'runState' in upstream && !!upstream.runState,
chipIcon: foldedErrors > 0,
scriptChips:
header:
'runnableId' in upstream
? 1
: 'scripts' in upstream
? (upstream.scripts?.length ?? 0)
: 0
? { label: upstream.summary || upstream.path, icons: 1 }
: 'scripts' in upstream && upstream.scripts?.length
? upstream.scripts.length === 1
? {
label: upstream.scripts[0].summary || upstream.scripts[0].path,
icons: 1
}
: { label: `${upstream.scripts.length} scripts`, icons: 2 }
: undefined
})
: undefined
return {
@@ -1583,7 +1592,11 @@
)
})
let layoutPositions = $derived(
layoutAssetGraph(layoutInput, ADD_NODE_ID, assetsOnly ? PIPELINE_NODE_EXTRA_ROW : 0)
layoutAssetGraph(
layoutInput,
ADD_NODE_ID,
assetsOnly ? PIPELINE_NODE_EXTRA_ROW + PIPELINE_NODE_HEADER : 0
)
)
/** Assets-only asset cards size to their text; every other node is `NODE.width`. */
@@ -19,6 +19,7 @@
ShieldCheck,
ShieldAlert,
CheckCircle2,
ChevronDown,
CircleSlash,
Trash2,
XCircle
@@ -244,8 +245,7 @@
const n = upstream && 'scripts' in upstream ? upstream.scripts.length : 1
return `${n} script${n === 1 ? '' : 's'}`
})
const CHIP_CLASS =
'shrink-0 h-5 min-w-5 rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600'
const HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
@@ -322,37 +322,72 @@
)
</script>
{#snippet scriptChip(sc: {
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
selected?: boolean
onOpen: () => void
})}
<button
type="button"
class={twMerge(
CHIP_CLASS,
'grid place-items-center px-1',
sc.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
sc.selected && CHIP_SELECTED
)}
title={`Open ${sc.summary ? `${sc.summary} (${sc.path})` : sc.path}${sc.unsaved ? ' · draft' : ''}`}
aria-label="Open the script that produces this asset"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
sc.onOpen()
}}
>
{#if sc.language}
<LanguageIcon lang={sc.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
</button>
{#snippet scriptHeader()}
<!-- The script building this asset, folded into it: a strip across the top
naming it, which opens it. Several scripts open a menu to pick one. -->
{@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)}
{@const headerClass = twMerge(
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary'
)}
{#if one}
{@const isSelected = producer ? !!producer.selected : coSelected}
<button
type="button"
class={twMerge(headerClass, isSelected && HEADER_SELECTED)}
title={`Open ${one.summary ? `${one.summary} (${one.path})` : one.path}${one.unsaved ? ' · draft' : ''}`}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
one.onOpen()
}}
>
{#if one.language}
<LanguageIcon lang={one.language} width={11} height={11} />
{:else}
<Code2 size={11} class="shrink-0" />
{/if}
<span class="truncate">{one.summary || one.path}</span>
{#if one.unsaved}<span class="shrink-0 text-tertiary">draft</span>{/if}
{#if producer?.runState}
<RunStateChip runState={producer.runState} class="ml-auto h-4 px-1 rounded" />
{/if}
</button>
{:else if coProducers.length > 1}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full min-w-0 flex"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<DropdownV2
items={coProducerItems}
placement="bottom-start"
bind:open={scriptMenuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
class="w-full"
>
{#snippet buttonReplacement()}
<span
class={twMerge(headerClass, 'h-full', (coSelected || scriptMenuOpen) && HEADER_SELECTED)}
title={`Built by ${coProducers.length} scripts: pick one to open`}
>
{#each coProducers.slice(0, 2) as sc (sc.path)}
{#if sc.language}
<LanguageIcon lang={sc.language} width={11} height={11} />
{:else}
<Code2 size={11} class="shrink-0" />
{/if}
{/each}
<span class="truncate">{coProducers.length} scripts</span>
<ChevronDown size={11} class="shrink-0 ml-auto" />
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
{/snippet}
{#snippet upstreamRow()}
@@ -436,6 +471,7 @@
{selected}
tone={data.error ? 'error' : undefined}
subtitle={upstream ? upstreamRow : undefined}
header={producer || coProducers.length > 0 ? scriptHeader : undefined}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
>
@@ -554,50 +590,6 @@
<History size={12} />
</span>
{/if}
{#if producer}
{#if producer.runState}
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
{/if}
{@render scriptChip(producer)}
{:else if coProducers.length === 1}
{@render scriptChip({ ...coProducers[0], selected: coSelected })}
{:else if coProducers.length > 1}
<!-- Several scripts build this table: the first two languages and a
count, opening a menu to pick which one to open. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onpointerdown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<DropdownV2
items={coProducerItems}
placement="bottom-end"
bind:open={scriptMenuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
>
{#snippet buttonReplacement()}
<span
class={twMerge(
CHIP_CLASS,
'flex items-center gap-0.5 px-1',
(coSelected || scriptMenuOpen) && CHIP_SELECTED
)}
title={`Built by ${coProducers.length} scripts: pick one to open`}
>
{#each coProducers.slice(0, 2) as s (s.path)}
{#if s.language}
<LanguageIcon lang={s.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
{/each}
{#if coProducers.length > 2}
<span class="text-3xs leading-none">+{coProducers.length - 2}</span>
{/if}
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
@@ -6,7 +6,11 @@ and the controls hung around the card.
-->
<script lang="ts">
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_EXTRA_ROW, PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import {
PIPELINE_NODE_EXTRA_ROW,
PIPELINE_NODE_HEADER,
PIPELINE_NODE_HEIGHT
} from './assetGraphLayout'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
@@ -34,6 +38,9 @@ and the controls hung around the card.
width?: number
/** A line under the title, in the text column. */
subtitle?: Snippet
/** A tab on the card's top edge. Only on a card without `onclick`, since it
* may hold buttons of its own. */
header?: Snippet
}
let {
@@ -49,6 +56,7 @@ and the controls hung around the card.
leading,
trailing,
subtitle,
header,
width = NODE.width
}: Props = $props()
@@ -119,6 +127,19 @@ and the controls hung around the card.
>
{@render body()}
</button>
{:else if header}
<!-- The header is a tab on the card's top edge, inset from its sides. -->
<div class="flex flex-col" style="width: {width}px;">
<div
class="mx-2 flex items-stretch min-w-0 rounded-t-md overflow-hidden bg-surface-secondary border border-b-0 border-gray-200 dark:border-gray-700"
style="height: {PIPELINE_NODE_HEADER}px;"
>
{@render header()}
</div>
<div class={cardClass} style="min-height: {minHeight}px;" title={tooltip}>
{@render body()}
</div>
</div>
{:else}
<div
class={cardClass}
@@ -21,6 +21,8 @@ const COMPONENT_GAP = SIBLING_GAP * 2
/** Extra height of an assets-only node: the trigger chip under its title. */
export const PIPELINE_NODE_EXTRA_ROW = 16
/** Height of the header strip naming an assets-only node's producing script. */
export const PIPELINE_NODE_HEADER = 22
interface GraphInput {
/** `width` defaults to `NODE.width`. */
@@ -18,8 +18,8 @@ function textWidth(text: string, rem: number): number {
/**
* Width of an assets-only asset card, from the pieces `PipelineNodeCard` and
* `AssetNode` lay out: icon, the widest of kind / title / trigger chip, and the
* run and script chips. The numbers mirror their Tailwind spacing; change one
* `AssetNode` lay out: icon and the widest of kind / title / trigger chip, or the
* header naming the producing script, if wider. The numbers mirror their Tailwind spacing; change one
* and the other drifts. Clamped to the regular node width, past which the title
* truncates as usual.
*/
@@ -30,8 +30,9 @@ export function assetsOnlyNodeWidth(n: {
/** The chip leads with an icon (the folded-errors Fix chip). */
chipIcon?: boolean
runState: boolean
/** Producing scripts: one chip for one, two icons and a "+N" for more. */
scriptChips: number
/** The header's text (a script's summary or path, "N scripts"), with its icon
* count; undefined without a header. */
header?: { label: string; icons: number }
}): number {
const icon = 12 + 14 + 10
const text = Math.max(
@@ -39,14 +40,18 @@ export function assetsOnlyNodeWidth(n: {
textWidth(n.title, 0.75),
textWidth(n.chip, 0.65) + 12 + 2 + (n.chipIcon ? 14 : 0)
)
// The run chip is sized for a two-digit count, so a new run never re-lays the graph.
const scripts =
n.scriptChips === 0
? 0
: n.scriptChips === 1
? 20
: 8 + 2 + 12 * 2 + 2 + (n.scriptChips > 2 ? textWidth(`+${n.scriptChips - 2}`, 0.65) + 2 : 0)
const trailing = (n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + scripts + 8
const width = Math.ceil(icon + text + 4 + trailing + 2 + 8)
return Math.min(NODE.width, Math.max(MIN_WIDTH, width))
const body = Math.ceil(icon + text + 4 + 8 + 2 + 8)
// Header: px-2, icons with gap-1.5, the label, then the run chip (sized for a
// two-digit count, so a new run never re-lays the graph) or the menu chevron.
const header = n.header
? Math.ceil(
8 +
n.header.icons * (11 + 6) +
textWidth(n.header.label, 0.65) +
6 +
(n.runState ? 11 + 4 + textWidth('×99', 0.65) + 8 : 11) +
8
)
: 0
return Math.min(NODE.width, Math.max(MIN_WIDTH, body, header))
}