collapse detail page header actions into menus below lg (#11163)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-16 12:05:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 49d0310ecc
commit 6885226c26
9 changed files with 179 additions and 80 deletions
@@ -53,12 +53,17 @@
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
{/if}
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{item.displayName}
</p>
<div class="grow min-w-0 text-left">
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate whitespace-nowrap"
>
{item.displayName}
</p>
{#if item.description}
<p class="text-2xs text-secondary">{item.description}</p>
{/if}
</div>
{@render item.extra?.()}
{#if item.shortcut || item.selected || item.toggle !== undefined}
<!-- Single trailing group so `shortcut` and `selected` can coexist:
@@ -16,6 +16,7 @@
type MenuItem = {
label: string
description?: string
onClick?: (e?: Event) => void
href?: string
icon?: any
@@ -146,6 +147,7 @@
const items = typeof menuItems === 'function' ? menuItems() : menuItems
return items.map((item) => ({
displayName: item.label,
description: item.description,
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
icon: item.icon,
disabled: item.disabled ?? false,
@@ -6,14 +6,23 @@
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext, tick } from 'svelte'
import { MediaQuery } from 'svelte/reactivity'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import type { TriggerContext } from '../triggers'
import { Calendar } from 'lucide-svelte'
import type { Item } from '$lib/utils'
import { Bell, BellOff, Calendar } from 'lucide-svelte'
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
type MainButton = {
label: string
href: string
/** Shown under the label once the button has collapsed into 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 }
}
type ButtonProps = any
@@ -59,6 +68,70 @@
}: Props = $props()
const dispatch = createEventDispatcher()
// Tailwind's `lg`, matched in JS so the one ellipsis menu can carry the collapsed buttons.
const wide = new MediaQuery('(min-width: 1024px)')
const barButtons = $derived(wide.current ? mainButtons : mainButtons.filter((b) => !b.narrow))
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.narrow !== 'object') return undefined
const label = btn.narrow.dropdownOf
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
}
async function toggleErrorHandler() {
const next = await toggleWorkspaceErrorHandler(
errorHandlerKind,
scriptOrFlowPath,
errorHandlerMuted
)
if (next !== undefined) errorHandlerMuted = next
}
const allMenuItems: Item[] = $derived([
...(wide.current ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
displayName: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
href: b.buttonProps.href,
action: b.buttonProps.onClick,
disabled: b.buttonProps.disabled,
type: 'action' as const
})),
...(wide.current
? []
: [
{
displayName: errorHandlerMuted ? 'Unmute error handler' : 'Mute error handler',
icon: errorHandlerMuted ? BellOff : Bell,
action: toggleErrorHandler,
type: 'action' as const
}
]),
...menuItems.map((item, i) => ({
displayName: item.label,
icon: item.Icon,
action: item.onclick,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wide.current
}))
])
function dropdownItemsOf(host: MainButton) {
if (wide.current) return undefined
const items = mainButtons
.filter((b) => dropdownHost(b) === host)
.map((b) => ({
label: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
href: b.buttonProps.href,
onClick: b.buttonProps.onClick,
disabled: b.buttonProps.disabled
}))
return items.length > 0 ? items : undefined
}
</script>
<div class="border-b">
@@ -103,38 +176,26 @@
{@render trigger_badges?.()}
</div>
<div class="flex gap-1 items-center pr-4">
{#if menuItems.length > 0}
{#key menuItems}
<DropdownV2
items={menuItems.map((item) => ({
displayName: item.label,
icon: item.Icon,
action: item.onclick,
type: item.color === 'red' ? 'delete' : 'action'
}))}
placement="bottom-end"
size="md"
/>
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
{/key}
{/if}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
bind:errorHandlerMuted
/>
{#each mainButtons as btn}
{#if wide.current}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
bind:errorHandlerMuted
/>
{/if}
{#each barButtons as btn (btn.label)}
{@const dropdownItems = dropdownItemsOf(btn)}
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
btnClasses="hidden md:flex items-center gap-1 whitespace-nowrap"
>
{btn.label}
</Button>
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
iconOnly
btnClasses="flex md:hidden items-center gap-1 whitespace-nowrap"
{dropdownItems}
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
btnClasses="flex items-center gap-1 whitespace-nowrap"
>
{btn.label}
</Button>
@@ -2,58 +2,21 @@
import { Bell, BellOff } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import Tooltip from '../Tooltip.svelte'
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
interface Props {
kind: 'script' | 'flow';
scriptOrFlowPath: string;
errorHandlerMuted: boolean | undefined;
iconOnly?: boolean;
kind: 'script' | 'flow'
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
iconOnly?: boolean
}
let {
kind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
iconOnly = true
}: Props = $props();
let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props()
async function toggleErrorHandler(): Promise<void> {
if ($workspaceStore !== undefined) {
try {
if (kind === 'flow') {
await FlowService.toggleWorkspaceErrorHandlerForFlow({
workspace: $workspaceStore,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
} else {
await ScriptService.toggleWorkspaceErrorHandlerForScript({
workspace: $workspaceStore,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
}
} catch (error) {
sendUserToast(
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
true
)
return
}
errorHandlerMuted = !errorHandlerMuted
sendUserToast(
errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active',
false
)
}
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
if (next !== undefined) errorHandlerMuted = next
}
</script>
@@ -0,0 +1,39 @@
import { get } from 'svelte/store'
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
/** Flips the workspace error handler for one script or flow and reports the outcome in a
* toast. Returns the new muted state, or undefined when nothing changed. */
export async function toggleWorkspaceErrorHandler(
kind: 'script' | 'flow',
path: string,
muted: boolean | undefined
): Promise<boolean | undefined> {
const workspace = get(workspaceStore)
if (workspace === undefined) return undefined
const next = !muted
try {
if (kind === 'flow') {
await FlowService.toggleWorkspaceErrorHandlerForFlow({
workspace,
path,
requestBody: { muted: next }
})
} else {
await ScriptService.toggleWorkspaceErrorHandlerForScript({
workspace,
path,
requestBody: { muted: next }
})
}
} catch (error) {
sendUserToast(
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
true
)
return undefined
}
sendUserToast(next ? 'Workspace error handler muted' : 'Workspace error handler active', false)
return next
}
+2
View File
@@ -1669,6 +1669,8 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
export type Item = {
displayName: string
/** Second line under the label, for an action whose name alone does not say what it does. */
description?: string
action?: (e: MouseEvent) => void
icon?: any
iconColor?: string
+13
View File
@@ -43,6 +43,19 @@ export function editInForkLabel(
return dev ? `Edit in ${dev.name}` : 'Edit in fork'
}
/** One line under `editInForkLabel` for a menu entry, saying where the edit happens. */
export function editInForkDescription(
itemType: ItemType,
currentWorkspaceId: string | undefined,
allWorkspaces: UserWorkspace[]
): string {
const kind = itemType === 'raw_app' ? 'app' : itemType
const dev = findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
return dev
? `Open this ${kind} in the ${dev.name} dev workspace`
: `Edit this ${kind} in a forked workspace, then deploy the changes back`
}
/**
* Whether the user may CREATE a new fork of the current workspace: forking not disabled, or the user
* can bypass the rule (workspace admins). Keeps the "Fork workspace" entry available to admins as the
@@ -80,6 +80,7 @@
import {
buildForkEditUrl,
editInForkAllowed,
editInForkDescription,
editInForkLabel,
onEditInForkClick
} from '$lib/utils/editInFork'
@@ -301,6 +302,8 @@
if (flow && !$userStore?.operator) {
buttons.push({
label: 'Fork',
description: `Start a new flow from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: `${base}/flows/add?template=${flow.path}`,
variant: 'subtle',
@@ -319,6 +322,8 @@
) {
buttons.push({
label: editInForkLabel($workspaceStore, $userWorkspaces),
description: editInForkDescription('flow', $workspaceStore, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('flow', flow.path),
onClick: (e: Event | undefined) =>
@@ -346,6 +351,7 @@
buttons.push({
label: `History`,
narrow: 'menu',
buttonProps: {
onClick: () => flowHistory?.open(),
unifiedSize: 'md',
@@ -361,6 +367,7 @@
if (!$userStore?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
@@ -103,6 +103,7 @@
import {
buildForkEditUrl,
editInForkAllowed,
editInForkDescription,
editInForkLabel,
onEditInForkClick
} from '$lib/utils/editInFork'
@@ -459,6 +460,8 @@
if (!topHash && script && !$userStore?.operator && !script.codebase) {
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: `${base}/scripts/add?template=${script.path}`,
unifiedSize: 'md',
@@ -477,6 +480,8 @@
) {
buttons.push({
label: editInForkLabel($workspaceStore, $userWorkspaces),
description: editInForkDescription('script', $workspaceStore, $userWorkspaces),
narrow: { dropdownOf: 'Edit' },
buttonProps: {
href: buildForkEditUrl('script', script.path),
onClick: (e: Event | undefined) =>
@@ -509,6 +514,7 @@
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
narrow: 'menu',
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
@@ -524,6 +530,7 @@
if (!$userStore?.operator) {
buttons.push({
label: 'Build app',
narrow: 'menu',
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)