diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index c6bb223f18..a014d1835d 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -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 @@ {#snippet summaryContent()} - +
+ + {#if name_marks}{@render name_marks()}{/if} +
{/snippet} + {#if workspace && flow && workspace === $workspaceStore} + + {/if} + {/snippet} {#snippet trigger_badges()} {/snippet} - {#if workspace && flow} - - {#if workspace === $workspaceStore} - - {/if} - {/if} 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()} + + {#if workspace && script && workspace === $workspaceStore} + + {/if} + {/snippet} {#snippet trigger_badges()} {/snippet} - {#if workspace && script} - - {#if workspace === $workspaceStore} - - {/if} - {/if} {#if script?.auto_kind === 'wac'} {#snippet text()} diff --git a/frontend/src/lib/components/pageHeaderStages.test.ts b/frontend/src/lib/components/pageHeaderStages.test.ts index 38be3d898e..13b6f93e58 100644 --- a/frontend/src/lib/components/pageHeaderStages.test.ts +++ b/frontend/src/lib/components/pageHeaderStages.test.ts @@ -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', () => { diff --git a/frontend/src/lib/components/pageHeaderStages.ts b/frontend/src/lib/components/pageHeaderStages.ts index e10dc819aa..95725f6ab2 100644 --- a/frontend/src/lib/components/pageHeaderStages.ts +++ b/frontend/src/lib/components/pageHeaderStages.ts @@ -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 = { 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 = { 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