feat: keep secondary detail actions in menus and the star beside the summary

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:26:56 -07:00
co-authored by Claude Opus 5.5
parent 539ad9ef2e
commit 66195ce9dc
5 changed files with 79 additions and 55 deletions
@@ -17,14 +17,13 @@
type MainButton = {
label: string
/** Shown under the label once the button has collapsed into a menu. */
/** Shown under the label once the button is a row of a menu. */
description?: string
buttonProps: ButtonProps
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
* width. */
narrow?: 'menu' | { dropdownOf: string }
/** A secondary action, kept out of the bar at every width: in the ellipsis menu, or in the
* dropdown of the enabled bar button labelled `dropdownOf` (the menu when there is none).
* Unset makes it a bar button, which folds into the menu only when the bar is short. */
placement?: 'menu' | { dropdownOf: string }
}
type ButtonProps = any
@@ -70,6 +69,9 @@
wide?: boolean
/** Controls ahead of the menu, such as the way an agent's page runs it. */
leading_actions?: import('svelte').Snippet
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
* summary rather than among the badges and buttons at the far end. */
name_marks?: import('svelte').Snippet
}
let {
@@ -89,7 +91,8 @@
trigger_badges,
ownsPageHeader = false,
wide = true,
leading_actions
leading_actions,
name_marks
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -98,21 +101,38 @@
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
// own box is narrow while the window is not — a session's preview panel — and says so through
// `wide`. The band knows what its breadcrumb leaves, which the host cannot see, and folds the
// row once the breadcrumb has given all it can (`actionFolds` below).
const roomInBar = $derived(!ownsPageHeader || pageHeader.actionFold === 0)
const wideRow = $derived(wide && roomInBar)
// The band folds the bar buttons once its breadcrumb has given all it can (`actionFolds` below):
// first every one but the last, which is what the page is for (Edit), then that one too. The
// host knows when its own box is narrow while the window is not — a session's preview panel —
// and says so through `wide`, which only sends the error handler's toggle to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0)
const wideRow = $derived(wide && fold === 0)
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
const barButtons = $derived(fold >= 2 ? [] : fold === 1 ? rowButtons.slice(-1) : rowButtons)
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.narrow !== 'object') return undefined
const label = btn.narrow.dropdownOf
if (typeof btn.placement !== 'object') return undefined
const label = btn.placement.dropdownOf
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
}
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
* dropdown would have held, then the secondary actions with no bar button to hang from. */
const menuButtons = $derived.by(() => {
const folded = rowButtons.filter((b) => !barButtons.includes(b))
const homeless = mainButtons.filter((b) => b.placement && !dropdownHost(b))
const childOf = (host: MainButton) =>
homeless.filter(
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
)
const placed = new Set(folded.flatMap(childOf))
return [
...folded.flatMap((host) => [host, ...childOf(host)]),
...homeless.filter((b) => !placed.has(b))
]
})
async function toggleErrorHandler() {
if (!errorHandlerKind || !scriptOrFlowPath) return
const next = await toggleWorkspaceErrorHandler(
@@ -125,7 +145,7 @@
}
const allMenuItems: Item[] = $derived([
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
...menuButtons.map((b) => ({
displayName: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
@@ -150,12 +170,11 @@
action: item.onclick,
disabled: item.disabled,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wideRow
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
}))
])
function dropdownItemsOf(host: MainButton) {
if (wideRow) return undefined
const items = mainButtons
.filter((b) => dropdownHost(b) === host)
.map((b) => ({
@@ -171,15 +190,18 @@
</script>
{#snippet summaryContent()}
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
<div class="flex items-center gap-1 min-w-0">
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={renameKind}
compact
/>
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
</div>
{/snippet}
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
@@ -273,7 +295,7 @@
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
actionFolds={1}
actionFolds={2}
contexts={headerContexts}
separator="always"
/>
@@ -297,6 +319,7 @@
kind={renameKind}
/>
</div>
{@render name_marks?.()}
{@render badges('md')}
</div>
<div class="flex gap-1 items-center pr-4">
@@ -369,7 +369,7 @@
buttons.push({
label: 'Fork',
description: `Start a new flow from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/flows/add?template=${flow.path}`),
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
@@ -390,7 +390,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('flow', flow.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, flow.path),
@@ -418,7 +418,7 @@
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => flowHistory?.open(),
unifiedSize: 'md',
@@ -435,7 +435,7 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
@@ -682,6 +682,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && flow && workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -698,12 +704,6 @@
small={false}
/>
{/snippet}
{#if workspace && flow}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="flow" path={flow.path} summary={flow.summary} />
{/if}
{/if}
<OnBehalfOfBadge
onBehalfOf={flow?.on_behalf_of}
onBehalfOfEmail={flow?.on_behalf_of_email}
@@ -532,7 +532,7 @@
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: operatingHref(`/scripts/add?template=${script.path}`),
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
@@ -553,7 +553,7 @@
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
placement: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('script', script.path, workspace),
onClick: (e: Event | undefined) => editInFork(e, script.path),
@@ -586,7 +586,7 @@
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
@@ -602,7 +602,7 @@
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
placement: 'menu',
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
@@ -870,6 +870,12 @@
}
: undefined}
>
{#snippet name_marks()}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace && script && workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/snippet}
{#snippet trigger_badges()}
<TriggersBadge
showOnlyWithCount={true}
@@ -887,12 +893,6 @@
}}
/>
{/snippet}
{#if workspace && script}
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
{#if workspace === $workspaceStore}
<Star kind="script" path={script.path} summary={script.summary} />
{/if}
{/if}
{#if script?.auto_kind === 'wac'}
<Popover notClickable>
{#snippet text()}
@@ -37,16 +37,16 @@ describe('fitBar', () => {
it("takes the squeeze from the path's name before the summary", () => {
const fit = fitBar({
...base,
barWidth: 400,
barWidth: 360,
actions: [0],
folds: 0,
trail: trail.map((w) => w - 400),
summary: BAR_STAGES.map(() => 100),
natural: { family: 0, fork: 0, summary: 300, name: 300 }
})
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 204, so 244
// has to go: all 140 the name has above its floor, then 104 from the summary.
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 196 }, scopesGive: false })
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
// has to go: all 140 the name has above its floor, then 40 from the summary.
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
})
it('keeps the summary and drops the path when only one of them fits', () => {
@@ -80,8 +80,9 @@ export type Part = 'family' | 'fork' | 'summary' | 'name'
/** The trail at each stage with its names at their floors, and each name's own width. */
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
/** The width a part may truncate to before the next stage drops something whole. */
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 96, name: 160 }
/** The width a part may truncate to before the next stage drops something whole. The summary's
* is wide: below it the path goes rather than the summary losing most of what it says. */
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
/** Past the last stage and fold, the item's name and then its summary keep this much before the
* folders give any. */
export const SQUEEZED_FLOOR = 40