From 559ffb72853b692caa149a76cb5b558b6cc4fa43 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 22 Sep 2026 16:22:24 +0200 Subject: [PATCH] feat: one breadcrumb in the page header, and the banners that used to sit under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/BreadcrumbItemContent.svelte | 16 + .../lib/components/BreadcrumbSegment.svelte | 10 +- .../src/lib/components/EditorHeader.svelte | 29 +- .../src/lib/components/FlowBuilder.svelte | 80 ++++- .../lib/components/ForkWorkspaceBanner.svelte | 320 ++++-------------- .../src/lib/components/NavBreadcrumb.svelte | 306 +++++++++++++++++ .../components/NavBreadcrumbTrigger.svelte | 27 ++ .../src/lib/components/PageHeaderBar.svelte | 115 ++++--- .../lib/components/PageHeaderContent.svelte | 22 +- .../lib/components/SummaryPathDisplay.svelte | 24 +- .../components/WorkspaceDraftsBanner.svelte | 35 +- .../components/WorkspaceItemKindIcon.svelte | 19 ++ .../apps/editor/InWorkspaceAppViewer.svelte | 20 +- frontend/src/lib/components/breadcrumbPath.ts | 23 ++ .../details/DetailPageHeader.svelte | 14 +- .../details/ErrorHandlerToggleButton.svelte | 11 +- frontend/src/lib/components/flow_builder.ts | 3 + .../src/lib/components/home/HomeAIChat.svelte | 97 +++--- .../components/pageHeaderRegistry.svelte.ts | 50 ++- .../components/raw_apps/RawAppPreview.svelte | 2 +- .../sessions/SessionModeSwitch.svelte | 4 +- .../components/sessions/SessionWrapper.svelte | 203 +++++------ .../lib/components/sidebar/MenuButton.svelte | 7 +- .../components/sidebar/SidebarContent.svelte | 76 ++--- .../components/sidebar/WorkspaceMenu.svelte | 2 +- .../sidebar/WorkspaceScopeHeader.svelte | 17 +- .../src/lib/components/sidebar/navPages.ts | 61 ++++ .../src/routes/(root)/(logged)/+layout.svelte | 84 ++--- .../flows/edit/[...path]/+page.svelte | 1 + .../(root)/(logged)/sessions/+page.svelte | 60 ++-- 30 files changed, 1047 insertions(+), 691 deletions(-) create mode 100644 frontend/src/lib/components/BreadcrumbItemContent.svelte create mode 100644 frontend/src/lib/components/NavBreadcrumb.svelte create mode 100644 frontend/src/lib/components/NavBreadcrumbTrigger.svelte create mode 100644 frontend/src/lib/components/WorkspaceItemKindIcon.svelte create mode 100644 frontend/src/lib/components/breadcrumbPath.ts create mode 100644 frontend/src/lib/components/sidebar/navPages.ts diff --git a/frontend/src/lib/components/BreadcrumbItemContent.svelte b/frontend/src/lib/components/BreadcrumbItemContent.svelte new file mode 100644 index 0000000000..9e945586c7 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbItemContent.svelte @@ -0,0 +1,16 @@ + + + +{@render icon?.()} +{label} diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8a266e4fc4..4d55675f26 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to close siblings. --> {#if showBanner} - -
-
- -
-
- -
- - {isDevWorkspace - ? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of` - : 'Fork of'} - {parentWorkspaceData?.name}{#if parentWorkspaceData?.name !== parentWorkspaceId} - ({parentWorkspaceId}){/if} - -
- - {#if loading} - Checking for changes... - {:else if error} - - {error} - - {:else if comparison} -
- {#if comparison.summary.total_diffs > 0} - - {forkAheadBehindMessage(changesAhead, changesBehind)} - {parentWorkspaceId} over {comparison - .summary.total_diffs} items - - - - - {#if ciTestTotal > 0} - - - {#if ciTestFailing > 0} -
- - CI: {ciTestFailing} failing -
- {:else if ciTestRunning > 0} -
- - CI: {ciTestRunning} running -
- {:else} -
- - CI: {ciTestPassing} passing -
- {/if} - {/if} - - {#if comparison.summary.conflicts > 0} - - -
- - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 - ? 's' - : ''} -
- {/if} - {:else if comparison.skipped_comparison} - - This {currentNoun} was created before the addition of certain windmill features, and - therefore the changes with its parent workspace cannot be displayed. - {:else if showDraftsOnly} - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - - {:else} - Everything is up to date - {/if} -
- {/if} -
- -
- -
-
-
-
+ + {/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}