diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 25291fa5f0..5c3a9bc013 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -58,6 +58,9 @@ /** Workspace whose items the breadcrumb picker lists; defaults to the operating * workspace (see `useOperatingWorkspace`). */ workspaceId?: string + /** Lay the summary out after the path on one line, for the page header bar, rather than + * stacked under it. */ + inline?: boolean } let { @@ -72,7 +75,8 @@ summaryEditable = true, pathEditable = true, hidePath = false, - workspaceId + workspaceId, + inline = false }: Props = $props() let pathPopoverOpen = $state(false) @@ -145,7 +149,11 @@ } -
+
{#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}