From e0a33dc3d7e87f3277664c6d01c00acd032804b4 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 21 Sep 2026 11:09:56 +0200 Subject: [PATCH 01/20] wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/copilot/AskAiButton.svelte | 3 +- .../copilot/chat/AiChatLayout.svelte | 8 +- .../components/copilot/chat/global/core.ts | 2 +- .../components/copilot/chat/global/gate.ts | 5 +- .../lib/components/flows/FlowEditor.svelte | 4 +- .../src/lib/components/home/HomeAIChat.svelte | 14 +- .../raw_apps/RawAppTemplatePicker.svelte | 2 +- .../sessions/OpenInSessionButton.svelte | 10 +- .../components/sessions/SessionWrapper.svelte | 2 +- .../sessions/SessionsBetaBanner.svelte | 22 +- .../components/sidebar/OperatorMenu.svelte | 337 -------- .../components/sidebar/SettingsMenu.svelte | 17 +- .../components/sidebar/SidebarContent.svelte | 47 +- .../sidebar/SwitchWorkspaceSubmenu.svelte | 68 -- .../components/sidebar/WorkspaceMenu.svelte | 1 + .../sidebar/WorkspacePickerBody.svelte | 5 +- .../components/sidebar/navDetached.svelte.ts | 20 + .../lib/components/sidebar/operatorRoutes.ts | 65 +- .../components/tutorials/OperatorTour.svelte | 20 +- .../lib/components/tutorials/operatorTour.ts | 2 - .../src/routes/(root)/(logged)/+layout.svelte | 728 +++++++++++------- .../src/routes/(root)/(logged)/+page.svelte | 4 +- .../apps_raw/edit/[...path]/+page.svelte | 2 +- .../(root)/(logged)/sessions/+page.svelte | 25 +- 24 files changed, 571 insertions(+), 842 deletions(-) delete mode 100644 frontend/src/lib/components/sidebar/OperatorMenu.svelte delete mode 100644 frontend/src/lib/components/sidebar/SwitchWorkspaceSubmenu.svelte create mode 100644 frontend/src/lib/components/sidebar/navDetached.svelte.ts diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 95ff47fb52..a141ee3125 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -5,7 +5,6 @@ import { AIBtnClasses } from './chat/AIButtonStyle' import { prefersSessionHandoff } from './chat/global/gate' import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte' - import { userStore } from '$lib/stores' interface Props { label?: string initialInput?: string @@ -16,7 +15,7 @@ // The label stays short ("Ask AI") for the search bar's inline row; the hover // text is where "new AI session" fits. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) + const handsOffToSession = $derived(prefersSessionHandoff()) export function onClick() { // No item to preview here — this carries a question, not a target — so the diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 4f3ef368ad..e2b8c9bba9 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -4,7 +4,7 @@ import AiChat from './AIChat.svelte' import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte' import { zIndexes } from '$lib/zIndexes' - import { userStore, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { copilotInfo } from '$lib/aiStore' @@ -48,8 +48,8 @@ }: Props = $props() // The desktop rail is fixed-positioned, so the content is offset by a matching - // left padding (in rem, matching the rail). Mobile/operator/borderless: no rail. - let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth) + // left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail. + let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth) $effect(() => { chatState.dockedChatAvailable = !disableAi @@ -88,7 +88,7 @@
- · - + + {#if !$userStore?.operator} + · + + {/if} {:else} Try AI Sessions (beta) · diff --git a/frontend/src/lib/components/sidebar/OperatorMenu.svelte b/frontend/src/lib/components/sidebar/OperatorMenu.svelte deleted file mode 100644 index 2d35ce7e10..0000000000 --- a/frontend/src/lib/components/sidebar/OperatorMenu.svelte +++ /dev/null @@ -1,337 +0,0 @@ - - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - 'transition-colors', - sidebarClasses.text, - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - logout()} - class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'cursor-pointer', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // This row expands the list below it instead of acting on the selection, and - // melt keeps the menu open only for a click it sees as defaultPrevented. - // Svelte delegates onclick to the root, which runs after melt's own listener, - // and a capture listener on the item itself would be ordered only by - // registration, so an ancestor's capture phase is what reliably wins. - e.preventDefault() - showExtraTriggers = !showExtraTriggers - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..fed773c84c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -18,7 +18,8 @@ Crown, Gauge, Trash2, - KeyRound + KeyRound, + GraduationCap } from 'lucide-svelte' import { base } from '$app/paths' import { goto } from '$lib/navigation' @@ -38,6 +39,8 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' + import { sidebarPageAllowed } from './operatorRoutes' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, workspaceStore, @@ -248,6 +251,15 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, + ...($userStore?.operator + ? [ + { + displayName: 'Take the tour', + icon: GraduationCap, + action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`) + } + ] + : []), ...(cloudHosted && $isPremiumStore === false ? [ { @@ -307,6 +319,7 @@ label="Workers" href="{base}/workers" icon={ServerCog} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -352,7 +365,7 @@ label="Audit logs" href="{base}/audit_logs" icon={Eye} - disabled={$userStore?.operator} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b43bc2988e..075e0f9018 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -72,6 +72,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' + import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -188,6 +189,11 @@ return count } + const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) + function allows(key: OperatorPageKey) { + return sidebarPageAllowed($userStore?.operator, currentWs, key) + } + const itemClass = twMerge( 'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' ) @@ -205,6 +211,7 @@ label: 'Runs', href: `${base}/runs`, icon: Play, + disabled: !allows('runs'), aiId: 'sidebar-menu-link-runs', aiDescription: 'Button to navigate to runs', onclick: () => { @@ -217,7 +224,7 @@ label: 'Variables', href: `${base}/variables`, icon: DollarSign, - disabled: $userStore?.operator, + disabled: !allows('variables'), aiId: 'sidebar-menu-link-variables', aiDescription: 'Button to navigate to variables' }, @@ -225,7 +232,7 @@ label: 'Resources', href: `${base}/resources`, icon: Boxes, - disabled: $userStore?.operator, + disabled: !allows('resources'), aiId: 'sidebar-menu-link-resources', aiDescription: 'Button to navigate to resources' }, @@ -233,6 +240,7 @@ label: 'Assets', href: `${base}/assets`, icon: Pyramid, + disabled: !allows('assets'), aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, @@ -240,7 +248,7 @@ label: 'Folders', href: `${base}/folders`, icon: FolderOpen, - disabled: $userStore?.operator, + disabled: !allows('folders'), aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, @@ -248,7 +256,7 @@ label: 'Groups', href: `${base}/groups`, icon: Users, - disabled: $userStore?.operator, + disabled: !allows('groups'), aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } @@ -259,7 +267,7 @@ label: 'HTTP', href: '/routes', icon: Route, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'http', aiId: 'sidebar-menu-link-http', aiDescription: 'Button to navigate to HTTP routes' @@ -268,7 +276,7 @@ label: 'WebSockets', href: '/websocket_triggers', icon: Unplug, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'ws', aiId: 'sidebar-menu-link-ws', aiDescription: 'Button to navigate to websocket triggers' @@ -277,7 +285,7 @@ label: 'Postgres', href: '/postgres_triggers', icon: Database, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'postgres', aiId: 'sidebar-menu-link-postgres', aiDescription: 'Button to navigate to Postgres triggers' @@ -286,7 +294,7 @@ label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'), href: '/kafka_triggers', icon: KafkaIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'kafka', aiId: 'sidebar-menu-link-kafka', aiDescription: 'Button to navigate to Kafka triggers' @@ -295,7 +303,7 @@ label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/nats_triggers', icon: NatsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'nats', aiId: 'sidebar-menu-link-nats', aiDescription: 'Button to navigate to NATS triggers' @@ -304,7 +312,7 @@ label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/sqs_triggers', icon: AwsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'sqs', aiId: 'sidebar-menu-link-sqs', aiDescription: 'Button to navigate to SQS triggers' @@ -313,7 +321,7 @@ label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'), href: '/gcp_triggers', icon: GoogleCloudIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'gcp', aiId: 'sidebar-menu-link-gcp', aiDescription: 'Button to navigate to GCP Pub/Sub triggers' @@ -322,7 +330,7 @@ label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'), href: '/azure_triggers', icon: AzureIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'azure', aiId: 'sidebar-menu-link-azure', aiDescription: 'Button to navigate to Azure Event Grid triggers' @@ -331,7 +339,7 @@ label: 'MQTT', href: '/mqtt_triggers', icon: MqttIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'mqtt', aiId: 'sidebar-menu-link-mqtt', aiDescription: 'Button to navigate to MQTT triggers' @@ -340,7 +348,7 @@ label: 'AMQP', href: '/amqp_triggers', icon: AmqpIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'amqp', aiId: 'sidebar-menu-link-amqp', aiDescription: 'Button to navigate to AMQP triggers' @@ -349,7 +357,7 @@ label: 'Email', href: '/email_triggers', icon: MailIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'email', aiId: 'sidebar-menu-link-email', aiDescription: 'Button to navigate to Email triggers' @@ -375,7 +383,7 @@ label: 'Schedules', href: `${base}/schedules`, icon: Calendar, - disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator, + disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'), aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, @@ -390,7 +398,6 @@ ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). - const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email) ) @@ -460,7 +467,7 @@ label: 'Workers', href: `${base}/workers`, icon: ServerCog, - disabled: $userStore?.operator, + disabled: !allows('workers'), aiId: 'sidebar-menu-link-workers', aiDescription: 'Button to navigate to workers' }, @@ -509,7 +516,7 @@ label: 'Audit logs', href: `${base}/audit_logs`, icon: Eye, - disabled: $userStore?.operator, + disabled: !allows('audit_logs'), aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' } @@ -551,7 +558,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if extraTriggerLinks.length > 0 && allows('triggers')} {#snippet triggr({ trigger })} - import { untrack } from 'svelte' - import { melt } from '@melt-ui/svelte' - import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte' - import { Building, ChevronRight } from 'lucide-svelte' - import { twMerge } from 'tailwind-merge' - import { sidebarClasses } from './MenuButton.svelte' - import WorkspacePickerBody from './WorkspacePickerBody.svelte' - import { MenuItem } from '$lib/components/meltComponents' - import { clearWorkspaceFromStorage } from '$lib/stores' - import { base } from '$lib/base' - - interface Props { - // Submenu builders from the enclosing melt Menu — createSubmenu must be - // called against this specific menu instance (same pattern as DropdownSubmenuItem). - builders: MenubarMenuBuilders - // Melt uses the parent menu's item builder for submenu rows too, so the - // picker body's MenuItems work here unchanged. - item: MenubarMenuElements['item'] - } - - let { builders, item }: Props = $props() - - const { - elements: { subTrigger, subMenu }, - states: { subOpen } - } = untrack(() => builders).createSubmenu() - - - - -{#if $subOpen} -
- - -
- clearWorkspaceFromStorage()} - class={twMerge( - 'w-full flex flex-row gap-2 px-4 py-2 font-normal', - sidebarClasses.text, - 'hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - All workspaces - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..84e03819f4 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -55,6 +55,7 @@ showChevron emphasizeLabel disableTitle + labelClass="wm-workspace-name" {trigger} /> diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..58fdbe9eae 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -50,8 +50,7 @@ strictWorkspaceSelect?: boolean // Set by a picker embedded in a page that drives its own navigation, so switching // workspaces must not navigate away from it. Separate from strictWorkspaceSelect, - // which only strips the sidebar-specific rows — the operator submenu sets that one - // and still wants the switch to move the page. + // which only strips the sidebar-specific rows. keepPageOnSwitch?: boolean } @@ -117,7 +116,7 @@ // scope header's WorkspaceFamilyPicker remains the primary fork surface. // // strictWorkspaceSelect is used where this list has no scope header beside it - // (standalone pages such as svix webhook creation, the operator menu), so + // (standalone pages such as svix webhook creation), so // there forks must stay directly selectable — list the full hierarchy // unconditionally. const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : []) diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..a5213b0336 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -0,0 +1,20 @@ +import { fromStore } from 'svelte/store' +import { userStore } from '$lib/stores' +import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + +// Empty until the user presses detach or attach once. Until then operators start detached: +// many of them navigate through a workspace's own app, and a docked sidebar would take its width. +const pref = useLocalStorageValue('nav_detached', '', 'string') +const user = fromStore(userStore) + +/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ +export const navDetached = { + get val(): boolean { + if (pref.val === 'true') return true + if (pref.val === 'false') return false + return !!user.current?.operator + }, + set val(detached: boolean) { + pref.val = String(detached) + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..7898eaf7ed 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -1,61 +1,22 @@ -import { base } from '$lib/base' +import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' +/** An `operator_settings` key: each admits an operator to one page (`triggers` to all of them). */ +export type OperatorPageKey = keyof OperatorSettings + /** - * The pages an operator can reach, each paired with the `operator_settings` key - * that admits them — the operator menu lists only the entries the active - * workspace permits. + * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page + * (other gates, such as admin-only entries, still apply); an operator sees only the pages the + * workspace's `operator_settings` turn on. */ -export type OperatorMenuLink = { - label: string - /** `operator_settings` key gating the page. */ - id: string - href: string +export function sidebarPageAllowed( + isOperator: boolean | undefined, + workspace: UserWorkspace | undefined, + key: OperatorPageKey +): boolean { + return !isOperator || workspace?.operator_settings?.[key] === true } -export type OperatorTriggerLink = OperatorMenuLink & { kind: string } - -/** Home needs no setting — it is the one page every operator always has. */ -export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [ - { label: 'Home', id: 'home', href: `${base}/` }, - { label: 'Runs', id: 'runs', href: `${base}/runs` }, - { label: 'Schedules', id: 'schedules', href: `${base}/schedules` } -] - -export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [ - { label: 'Resources', id: 'resources', href: `${base}/resources` }, - { label: 'Variables', id: 'variables', href: `${base}/variables` }, - { label: 'Assets', id: 'assets', href: `${base}/assets` }, - { label: 'Groups', id: 'groups', href: `${base}/groups` }, - { label: 'Folders', id: 'folders', href: `${base}/folders` }, - { label: 'Workers', id: 'workers', href: `${base}/workers` }, - { label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` } -] - -export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [ - { label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' }, - { label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' }, - { - label: 'Postgres triggers', - id: 'triggers', - href: `${base}/postgres_triggers`, - kind: 'postgres' - }, - { label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' }, - { label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' }, - { label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' }, - { label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' }, - { - label: 'Azure Event Grid triggers', - id: 'triggers', - href: `${base}/azure_triggers`, - kind: 'azure' - }, - { label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' }, - { label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' }, - { label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' } -] - /** * Whether the user is an operator in `workspace`. The server nulls * `operator_settings` for a non-operator, so a non-null value narrows an operator diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..51574ab96e 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ -
+
{#if !hidePath}
@@ -255,7 +263,7 @@ {/if} -
+
+ + +
+ {@render toggle?.()} + + + {#snippet children({ createMenu })} + + {/snippet} + + + {#if item} + item.path, (v) => item.onPathChange?.(v ?? '')} + bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')} + /> + {:else if section} + + {/if} + + {#if content?.actions} +
+ {@render content.actions()} +
+ {/if} +
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte new file mode 100644 index 0000000000..92859547e4 --- /dev/null +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index e2b8c9bba9..6069e09fb3 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -21,6 +21,8 @@ transitionClass?: string isMobile?: boolean children: any + /** The bar every route wears, rendered above the page content and inside the rail's offset. */ + header?: import('svelte').Snippet onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than @@ -41,6 +43,7 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, + header, onMenuOpen, disableAi, loadAiConfig = true, @@ -113,6 +116,7 @@
{@render burgerRow()} + {@render header?.()}
{@render children?.()}
@@ -142,6 +146,7 @@ style:padding-left="{contentPadLeft}rem" > {@render burgerRow()} + {@render header?.()}
{@render children?.()}
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts new file mode 100644 index 0000000000..604f581e16 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -0,0 +1,62 @@ +import type { Snippet } from 'svelte' +import type { WorkspaceItem, WorkspaceItemKind } from './workspacePicker' + +/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ +export type PageHeaderItem = { + kind: WorkspaceItemKind + path: string | undefined + /** The item's saved path, so a mid-rename breadcrumb and picker stay coherent. */ + savedPath?: string + summary?: string + raw_app?: boolean + /** Which workspace the breadcrumb's pickers read (a session editor acts on its fork). */ + workspaceId?: string + pathEditable?: boolean + summaryEditable?: boolean + onBehalfOfEmail?: string + onSummaryChange?: (summary: string) => void + onPathChange?: (path: string) => void + onNavigate?: (item: WorkspaceItem) => void +} + +/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */ +export type PageHeaderSection = { + label: string + /** Scopes the segment's picker, when the section maps to a kind of item. */ + kind?: WorkspaceItemKind +} + +export type PageHeaderContent = { + item?: PageHeaderItem + section?: PageHeaderSection + /** Rendered at the right end of the bar — the page's own buttons. */ + actions?: Snippet +} + +// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb +// and actions by registering here. +// +// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or +// summary the page edits reaches the bar through the layout's own dependency tracking, with no +// effect keeping two copies in step. +// +// Registrations stack and only the first shows. Pages register on mount, so the first entry is the +// outermost one — an editor nested in a session pane or a drawer is asked to register nothing, and +// a route mounting its successor before unmounting itself cannot flash the wrong path. +let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([]) +let nextId = 0 + +export const pageHeader = { + get content(): PageHeaderContent | undefined { + return entries[0]?.get() + }, + /** Registers on mount and returns the id to release on destroy. */ + register(get: () => PageHeaderContent): number { + const id = nextId++ + entries = [...entries, { id, get }] + return id + }, + release(id: number) { + entries = entries.filter((e) => e.id !== id) + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 6c47ac2dcf..c826ca8dca 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -81,6 +81,7 @@ import { twMerge } from 'tailwind-merge' import { navDetached } from '$lib/components/sidebar/navDetached.svelte' import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte' + import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import NavHandle from '$lib/components/sidebar/NavHandle.svelte' import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' @@ -1041,6 +1042,25 @@ + +{#snippet sidebarToggle()} + {@const hidden = navDetached.val} + + + {#snippet text()}{hidden ? 'Show sidebar' : 'Hide sidebar'}{/snippet} + +{/snippet} + {#snippet brandMark(collapsed: boolean)}
@@ -1198,31 +1218,10 @@ class="h-full flex flex-col" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG} > - -
- - {#snippet children({ createMenu })} - - {/snippet} - - {#if detachedFloating} - - - - {#snippet text()}Attach sidebar{/snippet} - - {/if} -
- + {#if !embedded && sessionsSwitchShown} - -
+
(preserveMenuOnNextNav = true)} @@ -1345,34 +1344,15 @@ )} onpointerdown={startSidebarResize} >
- -
- - {#snippet children({ createMenu })} - - {/snippet} - - {#if !isCollapsed} - - - - {#snippet text()}Detach sidebar{/snippet} - - {/if} -
- + {#if !embedded && sessionsSwitchShown} - -
+
{/if} @@ -1650,7 +1630,13 @@ transitionClass={sidebarTransitionClass} isMobile={useDrawer} onMenuOpen={() => navHandleSlot.open()} - /> + > + {#snippet header()} + {#if !menuHidden && !devOnly} + + {/if} + {/snippet} +
{:else} From 76254270090be468fe7daf6d0fb949063a5c1ded Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 22 Sep 2026 12:03:47 +0200 Subject: [PATCH 05/20] feat: detail pages fill the page header, which now spans above the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script or flow detail page no longer draws a bar of its own: its path becomes the header's breadcrumb, its summary keeps the rename-and-labels popover next to it, and its badges, menu and buttons ride along as the header's actions. The band runs the full width with the sidebar hanging below it. A snippet rendered in the header initialises its components under the header, not under the page that wrote it, so anything the page's tree provides is lost — the detail page's buttons read TriggerContext and threw. ContextBridge carries the entries a registration names back around the render. The floating handle is gone with it: the header's own toggle is the one control, hovering it peeks the sidebar in and clicking pins it. The workspace picker in the header is a denser variant of the sidebar's, and the card's outside-click catcher starts at the card rather than the viewport top, so it no longer swallows clicks meant for the toggle. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/ContextBridge.svelte | 18 +++ .../src/lib/components/EditorHeader.svelte | 29 ++-- .../src/lib/components/PageHeaderBar.svelte | 13 +- .../lib/components/PageHeaderContent.svelte | 4 +- .../lib/components/SummaryPathDisplay.svelte | 16 +- .../copilot/chat/AiChatLayout.svelte | 5 - .../details/DetailPageHeader.svelte | 146 +++++++++--------- .../components/pageHeaderRegistry.svelte.ts | 6 + .../lib/components/sidebar/MenuButton.svelte | 25 ++- .../lib/components/sidebar/NavHandle.svelte | 30 ---- .../components/sidebar/WorkspaceMenu.svelte | 15 +- .../src/routes/(root)/(logged)/+layout.svelte | 99 +++--------- 12 files changed, 196 insertions(+), 210 deletions(-) create mode 100644 frontend/src/lib/components/ContextBridge.svelte delete mode 100644 frontend/src/lib/components/sidebar/NavHandle.svelte diff --git a/frontend/src/lib/components/ContextBridge.svelte b/frontend/src/lib/components/ContextBridge.svelte new file mode 100644 index 0000000000..069bec0f42 --- /dev/null +++ b/frontend/src/lib/components/ContextBridge.svelte @@ -0,0 +1,18 @@ + + + +{@render children()} diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 5c3a9bc013..09cd894cde 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -55,6 +55,8 @@ * dropped, leaving only the summary. Used by the condensed session- * preview top bar to save vertical room. */ hidePath?: boolean + /** Drop the summary, for a host that renders its own after the path. */ + hideSummary?: boolean /** Workspace whose items the breadcrumb picker lists; defaults to the operating * workspace (see `useOperatingWorkspace`). */ workspaceId?: string @@ -75,6 +77,7 @@ summaryEditable = true, pathEditable = true, hidePath = false, + hideSummary = false, workspaceId, inline = false }: Props = $props() @@ -263,16 +266,18 @@ {/if} -
- -
+ {#if !hideSummary} +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index a63ea9aa70..66df89b64d 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -12,6 +12,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' import { pageHeader } from './pageHeaderRegistry.svelte' + import ContextBridge from './ContextBridge.svelte' import { kindKey, KIND_LABEL_LOWER } from '$lib/components/workspacePicker' interface Props { @@ -31,13 +32,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba {#snippet children({ createMenu })} - + {/snippet} {#if item} item.path, (v) => item.onPathChange?.(v ?? '')} bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')} /> + {#if item.summaryContent} +
{@render item.summaryContent()}
+ {/if} {:else if section}
{/if}
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 92859547e4..64c847ab3f 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -10,10 +10,10 @@ the header, so nesting is decided by the caller rather than guessed from context import { onMount } from 'svelte' import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' - let { item, section, actions }: PageHeaderContent = $props() + let { item, section, actions, contexts }: PageHeaderContent = $props() onMount(() => { - const id = pageHeader.register(() => ({ item, section, actions })) + const id = pageHeader.register(() => ({ item, section, actions, contexts })) return () => pageHeader.release(id) }) diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index 22925e98e7..edf866f5d3 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -28,6 +28,8 @@ editable?: boolean onSaved?: (newPath: string) => void kind?: 'flow' | 'script' + /** Drop the path line from the trigger, for a host that shows the path itself. */ + hidePath?: boolean } let { @@ -37,7 +39,8 @@ inheritedLabels = undefined, editable = false, onSaved, - kind = 'flow' + kind = 'flow', + hidePath = false }: Props = $props() let editSummary = $state('') @@ -110,9 +113,12 @@
- {path} + {#if !hidePath} + {path} + {/if}
- {#if !emptyString(summary)} + {#if !emptyString(summary) && !hidePath} {path} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 6069e09fb3..e2b8c9bba9 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -21,8 +21,6 @@ transitionClass?: string isMobile?: boolean children: any - /** The bar every route wears, rendered above the page content and inside the rail's offset. */ - header?: import('svelte').Snippet onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than @@ -43,7 +41,6 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - header, onMenuOpen, disableAi, loadAiConfig = true, @@ -116,7 +113,6 @@
{@render burgerRow()} - {@render header?.()}
{@render children?.()}
@@ -146,7 +142,6 @@ style:padding-left="{contentPadLeft}rem" > {@render burgerRow()} - {@render header?.()}
{@render children?.()}
diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 4f91f1800f..6a51b0cf03 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -3,11 +3,10 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' - 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 PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import type { TriggerContext } from '../triggers' import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' @@ -33,7 +32,10 @@ color?: 'red' } - const { triggersCount, triggersState } = $state(getContext('TriggerContext')) + const triggerContext = getContext('TriggerContext') + const { triggersCount, triggersState } = $state(triggerContext) + // The buttons below render in the page header, away from this page's tree. + const headerContexts = new Map([['TriggerContext', triggerContext]]) interface Props { mainButtons?: MainButton[] @@ -134,73 +136,73 @@ } -
-
-
+{/snippet} + +{#snippet actions()} + {#if tag} + tag: {tag} + {/if} + {@render children?.()} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)} + - {/if} - {@render trigger_badges?.()} -
-
- {#if allMenuItems.length > 0} - {#key allMenuItems} - - {/key} - {/if} - {#if wide.current} - - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - - {/each} -
-
-
-
+ {$triggersCount?.primary_schedule?.schedule ?? ''} + + {/if} + {@render trigger_badges?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + + {/key} + {/if} + {#if wide.current} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + + {/each} +{/snippet} + + + diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 604f581e16..da8af242b1 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -8,6 +8,9 @@ export type PageHeaderItem = { /** The item's saved path, so a mid-rename breadcrumb and picker stay coherent. */ savedPath?: string summary?: string + /** Rendered after the path instead of the editable summary — for a page whose summary is + * itself a control, such as a detail page's rename-and-labels popover. */ + summaryContent?: Snippet raw_app?: boolean /** Which workspace the breadcrumb's pickers read (a session editor acts on its fork). */ workspaceId?: string @@ -31,6 +34,9 @@ export type PageHeaderContent = { section?: PageHeaderSection /** Rendered at the right end of the bar — the page's own buttons. */ actions?: Snippet + /** Contexts those buttons look up. They render under the header, not under the page that + * wrote them, so anything the page's tree provides has to travel with them. */ + contexts?: Map } // The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 3d0286a4af..d5a67c6b40 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -54,6 +54,8 @@ // Classes for the label line only — `class` reaches the button, the label and the // sublabel alike, which is the wrong tool for colouring one line of the two. labelClass?: string | undefined + // Draw the colour disc at 20px rather than 26px, for denser rows such as the page header. + compact?: boolean } let { @@ -79,7 +81,8 @@ emphasizeLabel = false, disableTitle = false, ariaLabel = undefined, - labelClass = undefined + labelClass = undefined, + compact = false }: Props = $props() let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined) @@ -134,11 +137,23 @@ {#if icon} {@const SvelteComponent = icon} {#if color} - - - + {@const disc = compact ? 20 : 26} + {@const glyph = compact ? 12 : 16} + + + diff --git a/frontend/src/lib/components/sidebar/NavHandle.svelte b/frontend/src/lib/components/sidebar/NavHandle.svelte deleted file mode 100644 index 70a549a9a5..0000000000 --- a/frontend/src/lib/components/sidebar/NavHandle.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - -
navHandleSlot.open()}> - - navHandleSlot.open()} - /> - {#snippet text()}Open sidebar{/snippet} - -
diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 84e03819f4..759b44ee98 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -14,9 +14,17 @@ createMenu: MenubarBuilders['createMenu'] // When used outside of the side bar, where links to workspace settings and such don't make as much sense. strictWorkspaceSelect?: boolean + // Denser trigger for the page header bar: a smaller disc and a lighter name, so the + // workspace reads as the scope of the breadcrumb beside it rather than a heading. + compact?: boolean } - let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() + let { + isCollapsed = false, + createMenu, + strictWorkspaceSelect = false, + compact = false + }: Props = $props() // The active workspace's family root — shown in the trigger so a forked active workspace still // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly @@ -53,9 +61,10 @@ {isCollapsed} color={familyColor} showChevron - emphasizeLabel + emphasizeLabel={!compact} disableTitle - labelClass="wm-workspace-name" + {compact} + labelClass={compact ? 'wm-workspace-name text-primary font-medium' : 'wm-workspace-name'} {trigger} /> diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index c826ca8dca..b517e9b751 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -82,7 +82,6 @@ import { navDetached } from '$lib/components/sidebar/navDetached.svelte' import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' - import NavHandle from '$lib/components/sidebar/NavHandle.svelte' import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' @@ -95,7 +94,7 @@ import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import InstanceBanner from '$lib/components/InstanceBanner.svelte' import { onDestroy, setContext, untrack } from 'svelte' - import { cubicInOut, cubicOut } from 'svelte/easing' + import { cubicOut } from 'svelte/easing' import { base } from '$app/paths' import { Menubar, Tooltip } from '$lib/components/meltComponents' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' @@ -510,10 +509,6 @@ let detachedFloating = $derived(navDetached.val && innerWidth >= 768) // The handle and the edge band both open the card this layout owns. navHandleSlot.setOpener(() => (menuOpen = true)) - let onHome = $derived(page.url.pathname === `${base}/`) - let currentWorkspaceName = $derived( - $userWorkspaces?.find((w) => w.id === $workspaceStore)?.name ?? $workspaceStore ?? '' - ) // Set once the user docks or detaches, so the rail that mounts from then on skips its // app-entry animation (`wm-sidebar-in`) and only plays `railMorph`. @@ -540,36 +535,6 @@ (offscreen ? `transform:translateX(calc(${-u} * (100% + 0.5rem)));` : '') } } - const JOIN_PICKER_MS = 110 - // The home-page workspace name travels to the card's workspace picker as the card opens, - // and back as it closes, so the name reads as one label joining the picker. It aims where - // the picker will be once the card has finished sliding (the card's current translate is - // undone), grows to the picker's font size, and fades out as it arrives (in as it leaves), - // so the handover to the picker's own label has no hard cut. - // Any other mount or unmount of the name (arriving on or leaving home) plays nothing. - function joinPicker(node: HTMLElement) { - const card = document.querySelector('[data-nav-card]') - const target = card?.querySelector('.wm-workspace-name') - const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches - if (!card || !target || reduced) return { duration: 0 } - const slide = new DOMMatrix(getComputedStyle(card).transform).m41 - // Opening: the card is still off-screen, sliding in. Closing: it is still fully open. - const cardIsMoving = menuOpen ? slide !== 0 : slide === 0 - if (!cardIsMoving) return { duration: 0 } - const from = node.getBoundingClientRect() - const to = target.getBoundingClientRect() - const dx = to.left - (menuOpen ? slide : 0) - from.left - const dy = to.top + to.height / 2 - (from.top + from.height / 2) - const scale = - parseFloat(getComputedStyle(target).fontSize) / parseFloat(getComputedStyle(node).fontSize) - return { - duration: JOIN_PICKER_MS, - easing: cubicInOut, - css: (_t: number, u: number) => - `transform-origin: left center; transform: translate(${dx * u}px, ${dy * u}px) scale(${1 + (scale - 1) * u}); opacity: ${1 - u};` - } - } - // Matches the edge band's `duration-200`, so the drawer opens as the tint finishes. const EDGE_OPEN_DELAY_MS = 200 let edgeOpenTimer: ReturnType | undefined @@ -1046,9 +1011,13 @@ {#snippet sidebarToggle()} {@const hidden = navDetached.val} + - - - - + + {/if} + +{#snippet forkAction()} + {@const total = comparison?.summary.total_diffs ?? 0} + {@const conflicts = comparison?.summary.conflicts ?? 0} + {#if loading} + Checking for changes… + {:else if error} + Comparison failed + {:else if showDraftsOnly} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + + {:else if total > 0} + + {#if changesAhead > 0} + + ↑ {changesAhead} + + {/if} + {#if changesBehind > 0} + + ↓ {changesBehind} + + {/if} + {#if conflicts > 0} + + {conflicts} conflict{conflicts !== 1 ? 's' : ''} + + {/if} + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte new file mode 100644 index 0000000000..b856e1a1d6 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -0,0 +1,306 @@ + + + +{#snippet workspaceDisc()} + + + + + + +{/snippet} + +{#snippet forkIcon()} + +{/snippet} + +{#snippet itemKindIcon()} + {#if item}{/if} +{/snippet} + +{#snippet sectionKindIcon()} + {#if section?.kind}{/if} +{/snippet} + +{#snippet folderIcon()} + +{/snippet} + +{#snippet userIcon()} + +{/snippet} + +{#snippet slash()} + +{/snippet} + + diff --git a/frontend/src/lib/components/NavBreadcrumbTrigger.svelte b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte new file mode 100644 index 0000000000..f0b2084d46 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte @@ -0,0 +1,27 @@ + + + + diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 66df89b64d..e30c8bdda0 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -7,72 +7,79 @@ section name — and the route's own buttons at the far end. The row's height matches the sidebar's own header row, so the two read as one band. --> -
- {@render toggle?.()} - - - {#snippet children({ createMenu })} - - {/snippet} - - - {#if item} - item.path, (v) => item.onPathChange?.(v ?? '')} - bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')} - /> - {#if item.summaryContent} -
{@render item.summaryContent()}
- {/if} - {:else if section} - +
+ {#if navDetached.val} + + +
navHandleSlot.open()}> + + + {#snippet text()}Show sidebar{/snippet} + +
{/if} - {#if content?.actions} + + + {#if item && (item.summaryContent || item.summary)} + + + +
+ + {#if item.summaryContent} + {@render item.summaryContent()} + {:else} + {item.summary} + {/if} +
+ {/if} + + {#if actions.length > 0}
- {#key content.contexts} - - {@render content.actions()} - - {/key} + {#each actions as entry, i (i)} + {#key entry.contexts} + + {@render entry.render()} + + {/key} + {/each}
{/if}
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 64c847ab3f..1b26702cc1 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -10,10 +10,28 @@ the header, so nesting is decided by the caller rather than guessed from context import { onMount } from 'svelte' import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' - let { item, section, actions, contexts }: PageHeaderContent = $props() + let { + item, + section, + actions, + contexts, + barPlacement, + actingWorkspaceId, + barRightInset, + barBorder + }: PageHeaderContent = $props() onMount(() => { - const id = pageHeader.register(() => ({ item, section, actions, contexts })) + const id = pageHeader.register(() => ({ + item, + section, + actions, + contexts, + barPlacement, + actingWorkspaceId, + barRightInset, + barBorder + })) return () => pageHeader.release(id) }) diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index edf866f5d3..46aa489349 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -28,8 +28,8 @@ editable?: boolean onSaved?: (newPath: string) => void kind?: 'flow' | 'script' - /** Drop the path line from the trigger, for a host that shows the path itself. */ - hidePath?: boolean + /** Header variant: no path line (the host shows it) and a lighter summary. */ + compact?: boolean } let { @@ -40,7 +40,7 @@ editable = false, onSaved, kind = 'flow', - hidePath = false + compact = false }: Props = $props() let editSummary = $state('') @@ -111,9 +111,11 @@ > {#snippet trigger()}
- {#if !hidePath} + {#if !compact} {path} @@ -238,11 +242,13 @@ {/snippet} {:else} -
- {#if !emptyString(summary) && !hidePath} +
+ {#if !emptyString(summary) && !compact} {path} {/if} - + {emptyString(summary) ? (path ?? '') : summary}
diff --git a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte index 2e0110f710..2b153c27a8 100644 --- a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte +++ b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte @@ -1,10 +1,10 @@ {#if !isFork && draftCount > 0} - -
-
-
-
- - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - -
- - -
-
-
+ + {/if} + +{#snippet draftsAction()} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + +{/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemKindIcon.svelte b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte new file mode 100644 index 0000000000..9d07f6ef89 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte @@ -0,0 +1,19 @@ + + + +{#if kind === 'flow'} + +{:else if kind === 'app'} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 99ea07c8fd..86cb4c8482 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -12,6 +12,7 @@ import { base } from '$lib/base' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' import { userStore } from '$lib/stores' @@ -147,7 +148,20 @@ {#if canWriteApp && !hideEditBtn} -
- -
+ + {/if} + +{#snippet editAction()} + +{/snippet} diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..8c041518f5 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,23 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/` or + * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined for anything shorter than scope + name, such as a path still being typed. + */ +export function splitItemPath(path: string): { dirs: PathDir[]; leaf: PathDir } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + return { dirs, leaf: { name: slug[slug.length - 1], fullPath: path } } +} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 6a51b0cf03..b04ab7a28e 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -144,7 +144,7 @@ {inheritedLabels} {onSaved} kind={errorHandlerKind} - hidePath + compact /> {/snippet} @@ -159,7 +159,7 @@ btnClasses="inline-flex" startIcon={{ icon: Calendar }} variant="default" - unifiedSize="xs" + unifiedSize="sm" on:click={async () => { dispatch('seeTriggers') await tick() @@ -172,17 +172,23 @@ {@render trigger_badges?.()} {#if allMenuItems.length > 0} {#key allMenuItems} - + {/key} {/if} {#if wide.current} - + {/if} {#each barButtons as btn (btn.label)} {@const dropdownItems = dropdownItemsOf(btn)}
{/if} - {#if !sessionMode} - -
- -
- {/if} - {#if sessionMode}
{@render brandMark(false)} + {#if detachedFloating} +
+ {@render sidebarToggle()} +
+ {/if}
@@ -1274,7 +1270,8 @@ id="sidebar" class={classNames( dockToggled ? '' : 'wm-sidebar-in', - 'flex flex-col fixed top-12 bottom-0 z-40 ', + // The rail owns the full height of the left edge; the band starts to its right. + 'flex flex-col fixed inset-y-0 z-40 ', sidebarTransitionClass, devOnly ? '!hidden' : '' )} @@ -1304,18 +1301,13 @@ header, which is why nothing names the workspace here. --> {#if !embedded && sessionsSwitchShown}
- -
- {/if} - - {#if !sessionMode} - -
- +
+ +
{/if} @@ -1401,19 +1393,7 @@ > {@render brandMark(isCollapsed)}
- {#if isCollapsed} - - - - {#snippet text()}Detach sidebar{/snippet} - - {/if} + {@render sidebarToggle()} {#if !$userStore?.operator}
{/if} -
- {#if !menuHidden && !devOnly} - - +
+ {#if !menuHidden && !devOnly && pageHeader.content?.barPlacement !== 'inline'} + {@const rightInset = pageHeader.content?.barRightInset} + {@const leftInset = useDrawer ? 0 : railWidth} + {#if rightInset != null} + +
+ +
+ {:else} + +
+ +
+ {/if} {/if} {#if $enterpriseLicense && !menuHidden} + + +{#snippet sessionPageActions()} + {#if previewCollapsed && !fullscreen} + + + {/if} +{/snippet} +
{#if embedded} - + +
{#each warmSessions as s (s.id)}
- +
{/each}
@@ -925,7 +958,11 @@ maxSize={previewCollapsed ? 0 : 100} class="flex flex-col min-h-0" > -
+ +
- {/if}
{#if aiHiddenVerdict === undefined}
From 4293b7683a4e225b5953b9cd37af7cb49ccd4b42 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 22 Sep 2026 18:21:51 +0200 Subject: [PATCH 07/20] feat: the runs page's own header row moves into the page header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The title, the queue counters, the kind and status filters, the schedule and future-job switches, the search field and the timeframe picker all ride the band now, and the page starts at the chart — one row of vertical space back. The two toggle groups appear from 1500px: below that the bar cannot hold them next to the search field, and the same filtering is reachable from the search field's own kind and status chips. The search field is what gives width back, and the breadcrumb yields last, so a page's own name stays whole while the workspace and fork names truncate. The queue counters keep their icon form at every width, since the bar has room for two counters and never for the two labelled blocks. Their counts hang outside their icons, so the group carries the margins that keep a badge off its neighbour, and the suspended counter answers a hover at zero rather than sitting there as a dead icon. The band is 44px rather than 40, and a page can hand the breadcrumb a hint — the documentation tooltip that belongs with the page's name rather than with its buttons. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/NavBreadcrumb.svelte | 10 +- .../src/lib/components/PageHeaderBar.svelte | 18 +- .../lib/components/PageHeaderContent.svelte | 2 + frontend/src/lib/components/RunsPage.svelte | 439 +++++++++--------- .../toggleButton-v2/ToggleButtonMore.svelte | 9 +- .../components/pageHeaderRegistry.svelte.ts | 4 + .../src/lib/components/runs/RunsQueue.svelte | 65 ++- .../components/runs/TimeframeSelect.svelte | 19 +- .../(root)/(logged)/sessions/+page.svelte | 2 +- 9 files changed, 306 insertions(+), 262 deletions(-) diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index b856e1a1d6..586ebcc4a1 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -49,14 +49,18 @@ same weight, size and icon size, so the line reads as one control rather than fo type WorkspaceItem } from '$lib/components/workspacePicker' import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' + import type { Snippet } from 'svelte' let { item, section, + hint, actingWorkspaceId }: { item?: PageHeaderItem section?: PageHeaderSection + /** A page's own mark next to its name, such as its documentation tooltip. */ + hint?: Snippet /** The workspace the page acts on, when it differs from the one the app is pointed at. */ actingWorkspaceId?: string } = $props() @@ -296,11 +300,15 @@ same weight, size and icon size, so the line reads as one control rather than fo > {#if section.content}{@render section.content()}{:else}{section.label}{/if} + {#if hint}{@render hint()}{/if} {:else if routePage} {@const RouteIcon = routePage.icon} - + + {#if RouteIcon}{/if} {routePage.label} + {#if hint}{@render hint()}{/if} {/if} diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index e30c8bdda0..cf58195194 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -32,7 +32,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
@@ -54,7 +54,17 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{/if} - + +
+ +
{#if item && (item.summaryContent || item.summary)} +
{#each actions as entry, i (i)} {#key entry.contexts} diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 1b26702cc1..12ff926a11 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -13,6 +13,7 @@ the header, so nesting is decided by the caller rather than guessed from context let { item, section, + hint, actions, contexts, barPlacement, @@ -25,6 +26,7 @@ the header, so nesting is decided by the caller rather than guessed from context const id = pageHeader.register(() => ({ item, section, + hint, actions, contexts, barPlacement, diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index cac8952706..66deff1d80 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -44,6 +44,7 @@ import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import RunsQueue from '$lib/components/runs/RunsQueue.svelte' import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { pageHref, RUNS_PATH } from '$lib/components/sessions/previewPaths' import { twMerge } from 'tailwind-merge' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' @@ -584,6 +585,222 @@ let resolutionNote = $state('') +{#snippet runsHint()} + + All past and schedule executions of scripts and flows, including previews. You only see your own + runs or runs of groups you belong to unless you are an admin. + +{/snippet} + +{#snippet headerActions()} + +
+ { + jobsFilter('waiting') + }} + onJobsSuspended={() => { + jobsFilter('suspended') + }} + small + /> +
+ + + + + + + + jobsLoader?.loadJobs(true)} + loading={jobsLoader?.loading} + items={runsTimeframes} + bind:value={_timeframe.val} + /> + + pageHref(RUNS_PATH) + window.location.search, + workspaceId: $workspaceStore ?? undefined + }} + btnProps={{ unifiedSize: 'sm' }} + /> +{/snippet} + {:else}
- -
-
-

- Runs -

- - - All past and schedule executions of scripts and flows, including previews. You only see - your own runs or runs of groups you belong to unless you are an admin. - - - - { - jobsFilter('waiting') - }} - onJobsSuspended={() => { - jobsFilter('suspended') - }} - small={innerWidth < smallScreenWidth} - /> -
- - - - - - jobsLoader?.loadJobs(true)} - loading={jobsLoader?.loading} - items={runsTimeframes} - bind:value={_timeframe.val} - /> - - - pageHref(RUNS_PATH) + window.location.search, - workspaceId: $workspaceStore ?? undefined - }} - btnProps={{ unifiedSize: 'md' }} - /> -
+ + -
+
diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@
- + +
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 435da2757e..516e19965a 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -36,6 +36,9 @@ export type PageHeaderContent = { section?: PageHeaderSection /** Rendered at the right end of the bar — the page's own buttons. */ actions?: Snippet + /** Rendered right after the page's name in the breadcrumb, for what reads as part of that name + * rather than as an action — a page's documentation tooltip. */ + hint?: Snippet /** The workspace this page acts on, when it is not the one the app is pointed at — a session * running in a fork. The breadcrumb then names that workspace, and its fork part shows only * when it really is a fork. */ @@ -80,6 +83,7 @@ export const pageHeader = { const c = e.get() if (c.item !== undefined) merged.item = c.item if (c.section !== undefined) merged.section = c.section + if (c.hint !== undefined) merged.hint = c.hint if (c.contexts !== undefined) merged.contexts = c.contexts if (c.barPlacement !== undefined) merged.barPlacement = c.barPlacement if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId diff --git a/frontend/src/lib/components/runs/RunsQueue.svelte b/frontend/src/lib/components/runs/RunsQueue.svelte index 2cd2914878..4fbfe8842d 100644 --- a/frontend/src/lib/components/runs/RunsQueue.svelte +++ b/frontend/src/lib/components/runs/RunsQueue.svelte @@ -28,7 +28,9 @@ {#if small} {#snippet trigger()} -
+ +
{#if queue_count && ($queue_count ?? 0) > 0}
+ + {#snippet trigger()} -
+
- {:else} + {:else if suspended_count && ($suspended_count ?? 0) > 0} {@render suspendedContent()} {/if}
@@ -103,28 +102,28 @@ {/snippet} {#snippet suspendedContent()} - {#if suspended_count && ($suspended_count ?? 0) > 0} - - {#snippet tooltip()} - Jobs waiting for an event or approval before being resumed - {/snippet} -
0 - ? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center' - : ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
-
- -
-
- {/if} + + {#snippet tooltip()} + Jobs waiting for an event or approval before being resumed + {/snippet} +
0 + ? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center' + : ''} + > + {suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'} +
+
+ +
+
{/snippet} diff --git a/frontend/src/lib/components/runs/TimeframeSelect.svelte b/frontend/src/lib/components/runs/TimeframeSelect.svelte index f8d2b0df71..f376b082d7 100644 --- a/frontend/src/lib/components/runs/TimeframeSelect.svelte +++ b/frontend/src/lib/components/runs/TimeframeSelect.svelte @@ -77,7 +77,7 @@ -
+
{#if navDetached.val} -
navHandleSlot.open()}> +
navHandleSlot.open()} + onmouseleave={() => navHandleSlot.scheduleClose()} + >