mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: one breadcrumb in the page header, and the banners that used to sit under it
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7625427009
commit
559ffb7285
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
@component
|
||||
The inside of one breadcrumb part: an optional leading icon and the label.
|
||||
|
||||
Every part of the header's breadcrumb renders this, whether its dropdown is a melt menu or a
|
||||
popover, so they share one look. Separators are the host's job — a part must not contain the
|
||||
slash before it, or hovering the part would highlight the separator too.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let { label, icon }: { label: string; icon?: Snippet } = $props()
|
||||
</script>
|
||||
|
||||
{@render icon?.()}
|
||||
<span class="truncate">{label}</span>
|
||||
@@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to
|
||||
close siblings.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import WorkspaceItemDrillPicker, {
|
||||
type Scope
|
||||
@@ -15,7 +15,8 @@ close siblings.
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
withChevron?: boolean
|
||||
/** Leading glyph, e.g. the folder or user a path scope names. */
|
||||
icon?: import('svelte').Snippet
|
||||
extraClass?: string
|
||||
disabled?: boolean
|
||||
/** Where the picker lands when this segment is clicked. */
|
||||
@@ -33,7 +34,7 @@ close siblings.
|
||||
|
||||
let {
|
||||
label,
|
||||
withChevron = false,
|
||||
icon,
|
||||
extraClass = '',
|
||||
disabled = false,
|
||||
initialScope,
|
||||
@@ -71,8 +72,7 @@ close siblings.
|
||||
{disabled}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{#if withChevron}<ChevronRight size={10} class="shrink-0" /><span class="truncate">{label}</span
|
||||
>{:else}{label}{/if}
|
||||
<BreadcrumbItemContent {label} {icon} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<WorkspaceItemDrillPicker
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { ChevronRight, Pencil } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -14,6 +14,7 @@
|
||||
type WorkspaceItemKind
|
||||
} from '$lib/components/workspacePicker'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import { splitItemPath } from '$lib/components/breadcrumbPath'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
@@ -105,27 +106,7 @@
|
||||
// follows the live `path` — coherent with the picker because we inject a
|
||||
// virtual current-item entry at that same live path (see `currentItem`).
|
||||
let displayPath = $derived(snapshotPath ?? path ?? '')
|
||||
/** Breakdown for breadcrumb rendering. Top-level dirs (scopes) come first
|
||||
* — `f/<folder>` or `u/<user>` — then any number of subfolders, then the
|
||||
* item name. Each dir entry's `fullPath` is the cumulative path up to
|
||||
* that level, used to key the picker's `initialOpen` / `initialHighlight`. */
|
||||
let segments = $derived.by(() => {
|
||||
const parts = displayPath.split('/')
|
||||
if (parts.length < 3) return null
|
||||
const scope = parts.slice(0, 2).join('/')
|
||||
const slug = parts.slice(2)
|
||||
const dirs: { name: string; fullPath: string }[] = []
|
||||
// Top-level scope is a dir.
|
||||
dirs.push({ name: scope, fullPath: scope })
|
||||
// Intermediate slug parts (everything except the last one).
|
||||
let acc = scope
|
||||
for (let i = 0; i < slug.length - 1; i++) {
|
||||
acc = `${acc}/${slug[i]}`
|
||||
dirs.push({ name: slug[i], fullPath: acc })
|
||||
}
|
||||
const leaf = { name: slug[slug.length - 1], fullPath: displayPath }
|
||||
return { dirs, leaf }
|
||||
})
|
||||
let segments = $derived(splitItemPath(displayPath) ?? null)
|
||||
|
||||
// Treat an empty path as ownable so the pen popover lets a user pick the
|
||||
// path for a brand-new item. `Path.reset()` then synthesizes a default
|
||||
@@ -173,9 +154,9 @@
|
||||
{#if segments}
|
||||
{#each segments.dirs as dir, i (dir.fullPath)}
|
||||
{@const dKey = dirKey('all', dir.fullPath)}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={dir.name}
|
||||
withChevron
|
||||
extraClass={i === 0 ? 'gap-0.5 min-w-0 max-w-[40%]' : 'gap-0.5 min-w-0'}
|
||||
initialScope={i === 0
|
||||
? { kind: 'all' }
|
||||
@@ -188,9 +169,9 @@
|
||||
{/each}
|
||||
{@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)}
|
||||
{@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={segments.leaf.name}
|
||||
withChevron
|
||||
extraClass="gap-0.5 min-w-0"
|
||||
initialScope={leafParent ? { kind: 'all', dir: leafParent } : { kind: 'all' }}
|
||||
initialHighlight={leafKey}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
||||
|
||||
import { getContext, onDestroy, setContext, untrack } from 'svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -155,12 +156,16 @@
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts,
|
||||
onTestJob,
|
||||
condensedHeader = false
|
||||
condensedHeader = false,
|
||||
/** True for the route's own editor: its top bar becomes the page header. */
|
||||
ownsPageHeader = false
|
||||
}: FlowBuilderProps = $props()
|
||||
|
||||
// Top-bar button size + bar height. Condensed (session preview) uses the
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
|
||||
// In the page header the buttons ride a 40px band, the same size the condensed session-pane
|
||||
// header uses.
|
||||
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
|
||||
|
||||
// The workspace this editor operates on: deploy, save-draft, trigger loading
|
||||
// and the AutosaveIndicator all target it. Falls back to the global store, so
|
||||
@@ -1266,6 +1271,16 @@
|
||||
|
||||
setContext('FlowCopilotContext', flowCopilotContext)
|
||||
|
||||
// The header's action buttons render under the page header, not under this component, so the
|
||||
// contexts they look up have to travel with them. Read back here, after every setContext above.
|
||||
const headerContexts = new Map<any, any>([
|
||||
['FlowEditorContext', getContext('FlowEditorContext')],
|
||||
['TriggerContext', getContext('TriggerContext')],
|
||||
['FlowGraphAssetContext', getContext('FlowGraphAssetContext')],
|
||||
['FlowCopilotContext', flowCopilotContext],
|
||||
['customUi', customUi]
|
||||
])
|
||||
|
||||
let renderCount = $state(0)
|
||||
|
||||
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
|
||||
@@ -1525,6 +1540,28 @@
|
||||
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
|
||||
|
||||
<div bind:this={flowBuilderRoot} class="flex flex-col h-full">
|
||||
{#if ownsPageHeader}
|
||||
<!-- The editor's own top bar is the page header on this route: its breadcrumb and
|
||||
summary are the header's, and its buttons are the header's actions. -->
|
||||
<PageHeaderContent
|
||||
item={{
|
||||
kind: 'flow',
|
||||
path: $pathStore,
|
||||
savedPath: initialPath,
|
||||
summary: flowStore.val.summary,
|
||||
workspaceId: autosaveWorkspace,
|
||||
onBehalfOfEmail: $savedOnBehalfOfEmail,
|
||||
pathEditable: customUi?.topBar?.editablePath != false,
|
||||
summaryEditable: customUi?.topBar?.editableSummary != false,
|
||||
onPathChange: (v) => ($pathStore = v),
|
||||
onSummaryChange: (v) => (flowStore.val.summary = v),
|
||||
onNavigate: (item) => onNavigate?.(item)
|
||||
}}
|
||||
actions={flowHeaderActions}
|
||||
contexts={headerContexts}
|
||||
barBorder={false}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Nav between steps-->
|
||||
<div
|
||||
bind:clientWidth={topbarWidth}
|
||||
@@ -1597,6 +1634,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet flowHeaderActions()}
|
||||
|
||||
{#if $enterpriseLicense && !newFlow && !inSessionPane}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
it never shows on hover. pointer-events-none on the button lets the
|
||||
hover reach this titled wrapper instead. -->
|
||||
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize={headerBtnSize}
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={diffDisabled}
|
||||
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
||||
title={diffTitle}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !compactTopbar}
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
{loading}
|
||||
{loadingSave}
|
||||
unifiedSize={headerBtnSize}
|
||||
{dropdownItems}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Rendered either inline in the top bar (wide) or as a graph overlay
|
||||
(compactTopbar). Crossing the 720px threshold remounts
|
||||
FlowPreviewButtons; any open preview state will reset. -->
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Badge } from './common'
|
||||
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import type { WorkspaceComparison } from '$lib/gen'
|
||||
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
|
||||
import { Button } from './common'
|
||||
import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { childWorkspaceNoun, devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { diffActionableInDirection } from '$lib/utils_workspace_deploy'
|
||||
|
||||
let loading = $state(false)
|
||||
@@ -20,12 +20,10 @@
|
||||
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
// Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev
|
||||
// workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the
|
||||
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
|
||||
let isFork = $derived(parentWorkspaceId != null)
|
||||
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
|
||||
let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData))
|
||||
// Operators run scripts and flows, they never deploy a fork, so the banner and
|
||||
// its CTA are noise for them. Gates the fetches too, not just the markup: the
|
||||
@@ -67,14 +65,12 @@
|
||||
let showDraftsOnly = $derived(upToDate && draftCount > 0)
|
||||
|
||||
// Leaving for a workspace with no comparison of its own has to invalidate whatever
|
||||
// is in flight too, or that response lands as this one's answer — and its CI counts
|
||||
// would be fetched for paths this workspace may not even have.
|
||||
// is in flight too, or that response lands as this one's answer.
|
||||
function dropComparison() {
|
||||
requestSeq++
|
||||
comparison = undefined
|
||||
comparisonFor = undefined
|
||||
loading = false
|
||||
resetCiTestSummary()
|
||||
}
|
||||
|
||||
// `isNotOperator` is a dependency of its own: it only turns true once this
|
||||
@@ -113,8 +109,7 @@
|
||||
if (comparisonFor !== ws) {
|
||||
comparison = undefined
|
||||
comparisonFor = undefined
|
||||
resetCiTestSummary()
|
||||
}
|
||||
}
|
||||
const seq = ++requestSeq
|
||||
loading = true
|
||||
error = undefined
|
||||
@@ -159,68 +154,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let ciTestPassing = $state(0)
|
||||
let ciTestFailing = $state(0)
|
||||
let ciTestRunning = $state(0)
|
||||
let ciTestTotal = $state(0)
|
||||
|
||||
// These describe the rows of one comparison, so they are dropped with it.
|
||||
function resetCiTestSummary() {
|
||||
ciTestPassing = 0
|
||||
ciTestFailing = 0
|
||||
ciTestRunning = 0
|
||||
ciTestTotal = 0
|
||||
}
|
||||
|
||||
async function fetchCiTestSummary() {
|
||||
if (!$workspaceStore || !comparison?.diffs) return
|
||||
const items = comparison.diffs
|
||||
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
|
||||
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
|
||||
if (items.length === 0) return
|
||||
// Counted for the comparison current at call time — the poll below outlives a
|
||||
// fork switch, and a slow batch for the fork we left would repaint this one's.
|
||||
const seq = requestSeq
|
||||
try {
|
||||
const batch = await ScriptService.getCiTestResultsBatch({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { items }
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
let passing = 0
|
||||
let failing = 0
|
||||
let running = 0
|
||||
let total = 0
|
||||
for (const results of Object.values(batch)) {
|
||||
for (const r of results) {
|
||||
total++
|
||||
if (r.status === 'success') passing++
|
||||
else if (r.status === 'failure' || r.status === 'canceled') failing++
|
||||
else if (r.status === 'running' || (r.job_id && !r.status)) running++
|
||||
}
|
||||
}
|
||||
ciTestPassing = passing
|
||||
ciTestFailing = failing
|
||||
ciTestRunning = running
|
||||
ciTestTotal = total
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch CI test summary:', e)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (comparison && comparison.summary.total_diffs > 0) {
|
||||
fetchCiTestSummary()
|
||||
}
|
||||
})
|
||||
|
||||
// Poll while any CI test is still running
|
||||
$effect(() => {
|
||||
if (ciTestRunning <= 0) return
|
||||
const interval = setInterval(fetchCiTestSummary, 3000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
// Counted with the compare page's own predicate so the banner never advertises a
|
||||
// direction whose list is empty: the `ahead`/`behind` sums in the summary include
|
||||
// rows a direction does not carry, and miss a parent-only row that the update
|
||||
@@ -246,191 +179,60 @@
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<!-- Side padding mirrors the page content container below, so the banner
|
||||
stays aligned with it instead of bleeding to the viewport edges. -->
|
||||
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
|
||||
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
|
||||
<!-- The summary wraps inside its own column while the CTA keeps its width and
|
||||
stays on the first line: laid out as one non-wrapping row, the summary is
|
||||
long enough on a laptop-width viewport to push the button out of the
|
||||
banner instead of getting shorter. -->
|
||||
<div class="flex items-center justify-between gap-x-3">
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 min-w-0">
|
||||
<GitFork class="w-4 h-4 text-accent shrink-0" />
|
||||
<div class="text-sm min-w-0">
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100">
|
||||
{isDevWorkspace
|
||||
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
|
||||
: 'Fork of'}
|
||||
<b>{parentWorkspaceData?.name}</b
|
||||
>{#if parentWorkspaceData?.name !== parentWorkspaceId}
|
||||
({parentWorkspaceId}){/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
|
||||
{:else if error}
|
||||
<span class="text-xs text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
{:else if comparison}
|
||||
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0">
|
||||
{#if comparison.summary.total_diffs > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{forkAheadBehindMessage(changesAhead, changesBehind)}
|
||||
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
|
||||
.summary.total_diffs} items<span class="hidden lg:inline">:</span>
|
||||
</span>
|
||||
<!-- The per-kind breakdown is the first thing to go on a narrow
|
||||
viewport: the item total above already sizes the change set, and
|
||||
the compare page carries the detail. -->
|
||||
<div
|
||||
class="hidden lg:flex items-center flex-wrap gap-x-2 gap-y-1 whitespace-nowrap"
|
||||
>
|
||||
{#if comparison.summary.scripts_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.scripts_changed} script{comparison.summary
|
||||
.scripts_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.flows_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.apps_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.resources_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.resources_changed} resource{comparison.summary
|
||||
.resources_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.variables_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.variables_changed} variable{comparison.summary
|
||||
.variables_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.resource_types_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.resource_types_changed} resource type{comparison.summary
|
||||
.resource_types_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.folders_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.folders_changed} folder{comparison.summary
|
||||
.folders_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.schedules_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.schedules_changed} schedule{comparison.summary
|
||||
.schedules_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.triggers_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.triggers_changed} trigger{comparison.summary
|
||||
.triggers_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ciTestTotal > 0}
|
||||
-
|
||||
{#if ciTestFailing > 0}
|
||||
<div
|
||||
class="flex items-center gap-1 text-red-600 dark:text-red-400 whitespace-nowrap"
|
||||
>
|
||||
<CircleX class="w-3 h-3" />
|
||||
<span>CI: {ciTestFailing} failing</span>
|
||||
</div>
|
||||
{:else if ciTestRunning > 0}
|
||||
<div
|
||||
class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400 whitespace-nowrap"
|
||||
>
|
||||
<Loader2 class="w-3 h-3 animate-spin" />
|
||||
<span>CI: {ciTestRunning} running</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center gap-1 text-green-600 dark:text-green-400 whitespace-nowrap"
|
||||
>
|
||||
<CircleCheck class="w-3 h-3" />
|
||||
<span>CI: {ciTestPassing} passing</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if comparison.summary.conflicts > 0}
|
||||
-
|
||||
<div
|
||||
class="flex items-center gap-1 text-orange-600 dark:text-orange-400 whitespace-nowrap"
|
||||
>
|
||||
<AlertTriangle class="w-3 h-3" />
|
||||
<span
|
||||
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
|
||||
? 's'
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if comparison.skipped_comparison}
|
||||
<span class="text-blue-600 dark:text-blue-200">
|
||||
This {currentNoun} was created before the addition of certain windmill features, and
|
||||
therefore the changes with its parent workspace cannot be displayed.</span
|
||||
>
|
||||
{:else if showDraftsOnly}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer}
|
||||
>
|
||||
{#if showDraftsOnly}
|
||||
Review & deploy drafts
|
||||
{:else if !hasAnswer || changesAhead > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update {currentNoun}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- In the band, not a banner: the breadcrumb already says which fork this is, so all this has
|
||||
to carry is how far it has drifted and the way to review that. The per-kind breakdown, the
|
||||
CI line and the conflict count live on the compare page the button opens. -->
|
||||
<PageHeaderContent actions={forkAction} />
|
||||
{/if}
|
||||
|
||||
{#snippet forkAction()}
|
||||
{@const total = comparison?.summary.total_diffs ?? 0}
|
||||
{@const conflicts = comparison?.summary.conflicts ?? 0}
|
||||
{#if loading}
|
||||
<span class="text-2xs text-tertiary">Checking for changes…</span>
|
||||
{:else if error}
|
||||
<span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span>
|
||||
{:else if showDraftsOnly}
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openDraftCompare}
|
||||
title={`Review and deploy this workspace's drafts`}
|
||||
>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
{:else if total > 0}
|
||||
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
|
||||
to give its parent, behind is what it has yet to take. -->
|
||||
{#if changesAhead > 0}
|
||||
<Badge color="blue" small title="{changesAhead} ahead of {parentWorkspaceId}">
|
||||
↑ {changesAhead}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if changesBehind > 0}
|
||||
<Badge color="gray" small title="{changesBehind} behind {parentWorkspaceId}">
|
||||
↓ {changesBehind}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if conflicts > 0}
|
||||
<Badge color="orange" small title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''}">
|
||||
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openComparisonDrawer}
|
||||
title={`${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`}
|
||||
>
|
||||
{#if !hasAnswer || changesAhead > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update {currentNoun}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<!--
|
||||
@component
|
||||
The page header's breadcrumb: workspace / fork / kind / path, in one flat line.
|
||||
|
||||
Every segment is a trigger for the picker that changes it — the workspace menu, the fork family
|
||||
picker, and the item drill picker for the kind and each path level — and every segment wears the
|
||||
same weight, size and icon size, so the line reads as one control rather than four.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Building, Folder, GitFork, User } from 'lucide-svelte'
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { Menu, Menubar } from '$lib/components/meltComponents'
|
||||
import NavBreadcrumbTrigger from './NavBreadcrumbTrigger.svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
|
||||
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import {
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
workspaceColor,
|
||||
globalForkModal,
|
||||
userStore,
|
||||
superadmin
|
||||
} from '$lib/stores'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { fixupUrlAfterWorkspaceSwitch } from './sidebar/workspaceSwitchUrl'
|
||||
import { workspaceAIClients } from '$lib/components/copilot/lib'
|
||||
import {
|
||||
findWorkspaceRoot,
|
||||
findWorkspaceDescendants,
|
||||
isForkOwner
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { page } from '$app/state'
|
||||
import { navPageFor } from './sidebar/navPages'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { splitItemPath } from './breadcrumbPath'
|
||||
import {
|
||||
dirKey,
|
||||
KIND_LABEL_LOWER,
|
||||
kindKey,
|
||||
leafKeyFor,
|
||||
type WorkspaceItem
|
||||
} from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
|
||||
let {
|
||||
item,
|
||||
section,
|
||||
actingWorkspaceId
|
||||
}: {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
|
||||
actingWorkspaceId?: string
|
||||
} = $props()
|
||||
|
||||
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
|
||||
|
||||
// With no item or section registered, the route still says where the user is: Runs, Variables,
|
||||
// and the rest name themselves in the breadcrumb rather than leaving it at the workspace.
|
||||
// Home is the root the workspace part already stands for, so it names no page of its own.
|
||||
const routePage = $derived.by(() => {
|
||||
if (item || section) return undefined
|
||||
const found = navPageFor(page.url.pathname, base)
|
||||
return found?.path === '/' ? undefined : found
|
||||
})
|
||||
|
||||
/** One look for every segment, whichever picker it opens. */
|
||||
const SEGMENT =
|
||||
'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors'
|
||||
const ICON = 14
|
||||
|
||||
const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? []))
|
||||
const workspaceName = $derived(family?.name ?? scopeId ?? '')
|
||||
const discColor = $derived(family?.color ?? $workspaceColor ?? 'rgb(var(--color-surface-sunken))')
|
||||
const glyphColor = $derived(getContrastTextColor(discColor))
|
||||
|
||||
const forkable = useForkableWorkspaces({
|
||||
workspaces: () => $userWorkspaces,
|
||||
currentWorkspaceId: () => scopeId,
|
||||
isSuperadmin: () => !!$superadmin
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkable.current)
|
||||
const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId))
|
||||
const forkCount = $derived.by(() => {
|
||||
const root = findWorkspaceRoot(scopeId, forkableWorkspaces)
|
||||
return root ? findWorkspaceDescendants(root.id, forkableWorkspaces).length : 0
|
||||
})
|
||||
// A fork names itself; a root says how many it has, which is the hint that this opens them.
|
||||
const forkLabel = $derived(
|
||||
currentWs && currentWs.id !== family?.id
|
||||
? currentWs.name
|
||||
: `${forkCount} fork${forkCount === 1 ? '' : 's'}`
|
||||
)
|
||||
// Forks are a developer's workspace. An operator sees the segment only when one of them is
|
||||
// theirs — they were added to a fork, or are standing in one — and never the row that makes one.
|
||||
const inFork = $derived(!!currentWs?.parent_workspace_id)
|
||||
// A page acting on another workspace (a session) names that fork and nothing else: "0 forks"
|
||||
// would be about the workspace the user is browsing, not the one the session runs in.
|
||||
const showFork = $derived(
|
||||
actingWorkspaceId ? inFork : !$userStore?.operator || forkCount > 0 || inFork
|
||||
)
|
||||
|
||||
// A fork wears its workspace colour and, for a dev workspace, its environment badge — the same
|
||||
// two marks the fork chip carries everywhere else.
|
||||
const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined)
|
||||
const devBadge = $derived(
|
||||
inFork && currentWs?.is_dev_workspace ? devBadgeText(currentWs.dev_workspace_label) : undefined
|
||||
)
|
||||
|
||||
const canManageWorkspace = $derived(
|
||||
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
|
||||
)
|
||||
|
||||
const segments = $derived(item?.path ? splitItemPath(item.path) : undefined)
|
||||
const currentItem = $derived<(WorkspaceItem & { savedPath?: string }) | undefined>(
|
||||
item
|
||||
? {
|
||||
path: item.path ?? '',
|
||||
summary: item.summary ?? '',
|
||||
kind: item.kind,
|
||||
raw_app: item.raw_app,
|
||||
savedPath: item.savedPath
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the segment's icon. */
|
||||
const scopeKind = $derived(segments?.dirs[0]?.fullPath.split('/')[0])
|
||||
|
||||
function switchWorkspaceDirect(id: string) {
|
||||
if ($workspaceStore === id) return
|
||||
workspaceAIClients.init(id)
|
||||
switchWorkspace(id)
|
||||
void fixupUrlAfterWorkspaceSwitch(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet workspaceDisc()}
|
||||
<svg width={ICON} height={ICON} viewBox="0 0 16 16" class="flex-shrink-0">
|
||||
<circle cx="8" cy="8" r="8" fill={discColor} />
|
||||
<foreignObject x="3" y="3" width="10" height="10">
|
||||
<Building size={10} style={glyphColor ? `color: ${glyphColor}` : undefined} />
|
||||
</foreignObject>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet forkIcon()}
|
||||
<GitFork size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemKindIcon()}
|
||||
{#if item}<WorkspaceItemKindIcon kind={item.kind} size={ICON} />{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet sectionKindIcon()}
|
||||
{#if section?.kind}<WorkspaceItemKindIcon kind={section.kind} size={ICON} />{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet folderIcon()}
|
||||
<Folder size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet userIcon()}
|
||||
<User size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet slash()}
|
||||
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
{#snippet triggr({ trigger })}
|
||||
<NavBreadcrumbTrigger {trigger} class={SEGMENT} title={workspaceName}>
|
||||
<BreadcrumbItemContent label={workspaceName} icon={workspaceDisc} />
|
||||
</NavBreadcrumbTrigger>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody item={menuItem} />
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
|
||||
{#if showFork}
|
||||
{@render slash()}
|
||||
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={scopeId}
|
||||
{forkableWorkspaces}
|
||||
onPick={switchWorkspaceDirect}
|
||||
allowCreateFork={!$userStore?.operator}
|
||||
onRequestCreateFork={() => (globalForkModal.val = { opened: true })}
|
||||
settingsHref={canManageWorkspace ? `${base}/workspace_settings` : undefined}
|
||||
settingsLabel={`${currentWs?.name ?? scopeId ?? 'Workspace'} settings`}
|
||||
class="min-w-0"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- twMerge so the accent's text colour wins over the segment's own. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
forkAccent &&
|
||||
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
|
||||
)}
|
||||
style={forkAccent}
|
||||
>
|
||||
<BreadcrumbItemContent label={forkLabel} icon={forkIcon} />
|
||||
{#if devBadge}
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadge}</Badge
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
{/if}
|
||||
|
||||
{#if item || section || routePage}
|
||||
{@render slash()}
|
||||
{/if}
|
||||
|
||||
{#if item}
|
||||
{#if segments}
|
||||
{#each segments.dirs as dir, i (dir.fullPath)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<BreadcrumbSegment
|
||||
label={i === 0 ? dir.name.split('/').slice(1).join('/') : dir.name}
|
||||
icon={i === 0 ? (scopeKind === 'u' ? userIcon : folderIcon) : undefined}
|
||||
extraClass="{SEGMENT} {i === 0 ? 'max-w-[40%]' : ''}"
|
||||
initialScope={i === 0
|
||||
? { kind: 'all' }
|
||||
: { kind: 'all', dir: segments.dirs[i - 1].fullPath }}
|
||||
initialHighlight={dirKey('all', dir.fullPath)}
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
{/each}
|
||||
{@render slash()}
|
||||
<BreadcrumbSegment
|
||||
label={segments.leaf.name}
|
||||
extraClass={SEGMENT}
|
||||
initialScope={segments.dirs.length
|
||||
? { kind: 'all', dir: segments.dirs[segments.dirs.length - 1].fullPath }
|
||||
: { kind: 'all' }}
|
||||
initialHighlight={leafKeyFor(item.kind, segments.leaf.fullPath)}
|
||||
isCurrent
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
{:else}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[item.kind]}
|
||||
icon={itemKindIcon}
|
||||
extraClass={SEGMENT}
|
||||
initialHighlight={kindKey(item.kind)}
|
||||
isCurrent
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if section?.kind}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={SEGMENT}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{:else if section}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class="{SEGMENT} hover:bg-transparent hover:text-primary {section.content ? '' : 'truncate'}"
|
||||
aria-current="page"
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
{:else if routePage}
|
||||
{@const RouteIcon = routePage.icon}
|
||||
<span class="{SEGMENT} hover:bg-transparent hover:text-primary" aria-current="page">
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!--
|
||||
@component
|
||||
A breadcrumb segment that opens a melt menu.
|
||||
|
||||
The menu hands its trigger builder to a snippet, and a builder store cannot be subscribed inside
|
||||
one — so the segment takes it as a prop here, where both its attributes and its action are legal.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
trigger,
|
||||
class: className = '',
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
trigger?: any
|
||||
class?: string
|
||||
title?: string
|
||||
children: Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button use:conditionalMelt={trigger} {...$trigger} class={className} {title}>
|
||||
{@render children()}
|
||||
</button>
|
||||
@@ -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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import { PanelLeft } from 'lucide-svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { pageHeader } from './pageHeaderRegistry.svelte'
|
||||
import ContextBridge from './ContextBridge.svelte'
|
||||
import { kindKey, KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
|
||||
|
||||
interface Props {
|
||||
/** The sidebar's show/hide control, placed at the very start of the band. */
|
||||
toggle?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { toggle }: Props = $props()
|
||||
let { border = true }: { border?: boolean } = $props()
|
||||
|
||||
const content = $derived(pageHeader.content)
|
||||
const actions = $derived(pageHeader.actions)
|
||||
|
||||
// A route can drop the band's bottom edge when its own content draws one. The border stays in
|
||||
// the box either way — only its colour goes — so the band's contents do not shift a pixel when
|
||||
// a navigation swaps one kind of route for the other.
|
||||
const showBorder = $derived(content?.barBorder ?? border)
|
||||
|
||||
const item = $derived(content?.item)
|
||||
const section = $derived(content?.section)
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 h-12 px-2 border-b shrink-0 min-w-0">
|
||||
{@render toggle?.()}
|
||||
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} compact strictWorkspaceSelect={false} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
|
||||
{#if item}
|
||||
<EditorHeader
|
||||
inline
|
||||
hideSummary={!!item.summaryContent}
|
||||
kind={item.kind}
|
||||
raw_app={item.raw_app}
|
||||
savedPath={item.savedPath}
|
||||
workspaceId={item.workspaceId}
|
||||
onBehalfOfEmail={item.onBehalfOfEmail}
|
||||
pathEditable={item.pathEditable ?? true}
|
||||
summaryEditable={item.summaryEditable ?? true}
|
||||
onNavigate={item.onNavigate}
|
||||
bind:path={() => item.path, (v) => item.onPathChange?.(v ?? '')}
|
||||
bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')}
|
||||
/>
|
||||
{#if item.summaryContent}
|
||||
<div class="min-w-0">{@render item.summaryContent()}</div>
|
||||
{/if}
|
||||
{:else if section}
|
||||
<nav aria-label="Breadcrumb" class="contents">
|
||||
<BreadcrumbSegment
|
||||
label={section.kind ? KIND_LABEL_LOWER[section.kind] : section.label}
|
||||
isCurrent
|
||||
initialHighlight={section.kind ? kindKey(section.kind) : undefined}
|
||||
initialScope={section.kind ? { kind: section.kind } : undefined}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
</nav>
|
||||
<div
|
||||
data-page-header
|
||||
class="flex items-center gap-1 h-10 px-2 shrink-0 min-w-0 bg-surface border-b {showBorder
|
||||
? ''
|
||||
: 'border-transparent'}"
|
||||
>
|
||||
{#if navDetached.val}
|
||||
<!-- The only way back to a hidden sidebar: hovering slides the card in for a look, a click
|
||||
puts it back for good. Docked, the sidebar speaks for itself and nothing leads the bar. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div data-nav-handle onmouseenter={() => navHandleSlot.open()}>
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Show sidebar"
|
||||
onclick={() => (navDetached.val = false)}
|
||||
>
|
||||
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{#snippet text()}Show sidebar{/snippet}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if content?.actions}
|
||||
<NavBreadcrumb {item} {section} actingWorkspaceId={content?.actingWorkspaceId} />
|
||||
|
||||
{#if item && (item.summaryContent || item.summary)}
|
||||
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
|
||||
not another level of it. -->
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
|
||||
<!-- The item's kind belongs with the name a human reads, not with its path. -->
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<WorkspaceItemKindIcon kind={item.kind} />
|
||||
{#if item.summaryContent}
|
||||
{@render item.summaryContent()}
|
||||
{:else}
|
||||
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if actions.length > 0}
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
{#key content.contexts}
|
||||
<ContextBridge contexts={content.contexts}>
|
||||
{@render content.actions()}
|
||||
</ContextBridge>
|
||||
{/key}
|
||||
{#each actions as entry, i (i)}
|
||||
{#key entry.contexts}
|
||||
<ContextBridge contexts={entry.contexts}>
|
||||
{@render entry.render()}
|
||||
</ContextBridge>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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()}
|
||||
<div
|
||||
class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
|
||||
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
|
||||
? 'items-center px-1 py-0.5'
|
||||
: 'flex-col items-start px-2 py-1'}"
|
||||
>
|
||||
{#if !hidePath}
|
||||
{#if !compact}
|
||||
<span
|
||||
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
|
||||
>{path}</span
|
||||
@@ -121,7 +123,9 @@
|
||||
{/if}
|
||||
<div class="flex items-center gap-3 max-w-full">
|
||||
<span
|
||||
class="text-sm font-semibold truncate {emptyString(summary)
|
||||
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
|
||||
summary
|
||||
)
|
||||
? 'text-tertiary italic font-normal'
|
||||
: 'text-emphasis'}"
|
||||
>
|
||||
@@ -238,11 +242,13 @@
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="min-w-0 truncate flex flex-col px-2">
|
||||
{#if !emptyString(summary) && !hidePath}
|
||||
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
|
||||
{#if !emptyString(summary) && !compact}
|
||||
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-emphasis truncate">
|
||||
<span
|
||||
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
|
||||
>
|
||||
{emptyString(summary) ? (path ?? '') : summary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { Button } from './common'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { Badge, Button } from './common'
|
||||
import { goto } from '$app/navigation'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
|
||||
// Surfaces pending drafts (scripts/flows/apps) for the current workspace and
|
||||
// links to the compare page in draft mode. Mutually exclusive with
|
||||
@@ -29,23 +29,16 @@
|
||||
</script>
|
||||
|
||||
{#if !isFork && draftCount > 0}
|
||||
<!-- Side padding mirrors the page content container below, so the banner
|
||||
stays aligned with it instead of bleeding to the viewport edges. -->
|
||||
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
|
||||
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Pencil class="w-4 h-4 text-accent shrink-0" />
|
||||
<span class="text-xs font-medium text-blue-900 dark:text-blue-100">
|
||||
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Same button as the sibling ForkWorkspaceBanner CTA (they sit on the
|
||||
same home page), kept visually identical on purpose. -->
|
||||
<Button variant="default" unifiedSize="sm" onclick={openDraftCompare}>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- In the band rather than a banner over the page: it is a standing fact about the workspace,
|
||||
not an interruption, and one button says it and acts on it. -->
|
||||
<PageHeaderContent actions={draftsAction} />
|
||||
{/if}
|
||||
|
||||
{#snippet draftsAction()}
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
@component
|
||||
The glyph the rest of the app gives a workspace item's kind (see RowIcon), at the caller's size.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Code2, LayoutDashboard } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
|
||||
let { kind, size = 14 }: { kind: WorkspaceItemKind; size?: number } = $props()
|
||||
</script>
|
||||
|
||||
{#if kind === 'flow'}
|
||||
<BarsStaggered {size} class="flex-shrink-0 text-teal-500" />
|
||||
{:else if kind === 'app'}
|
||||
<LayoutDashboard {size} class="flex-shrink-0 text-orange-500" />
|
||||
{:else}
|
||||
<Code2 {size} class="flex-shrink-0 text-blue-500" />
|
||||
{/if}
|
||||
@@ -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 @@
|
||||
</PublicAppFrame>
|
||||
|
||||
{#if canWriteApp && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
|
||||
</div>
|
||||
<!-- The page header carries it, with the app's name: a floating button over the app's own
|
||||
canvas lands on whatever the app draws there. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: 'app', path, raw_app: true, summaryEditable: false, pathEditable: false }}
|
||||
actions={editAction}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#snippet editAction()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href={editHref}
|
||||
id="app-edit-btn">Edit</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -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/<folder>` or
|
||||
* `u/<user>`), 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 } }
|
||||
}
|
||||
@@ -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}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wide.current}
|
||||
<ErrorHandlerToggleButton kind={errorHandlerKind} {scriptOrFlowPath} bind:errorHandlerMuted />
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
unifiedSize="sm"
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
unifiedSize="sm"
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
scriptOrFlowPath: string
|
||||
errorHandlerMuted: boolean | undefined
|
||||
iconOnly?: boolean
|
||||
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props()
|
||||
let {
|
||||
kind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted = $bindable(),
|
||||
iconOnly = true,
|
||||
unifiedSize = 'md'
|
||||
}: Props = $props()
|
||||
|
||||
async function toggleErrorHandler(): Promise<void> {
|
||||
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
|
||||
@@ -24,7 +31,7 @@
|
||||
title={errorHandlerMuted === undefined || !errorHandlerMuted
|
||||
? 'Disable workspace error handler for this script'
|
||||
: 'Enable workspace error handler for this script'}
|
||||
unifiedSize="md"
|
||||
{unifiedSize}
|
||||
on:click={toggleErrorHandler}
|
||||
variant="subtle"
|
||||
startIcon={{
|
||||
|
||||
@@ -75,4 +75,7 @@ export type FlowBuilderProps = {
|
||||
// EditorHeader's path/breadcrumb row dropped (summary only). Used by the
|
||||
// session preview to save vertical room.
|
||||
condensedHeader?: boolean
|
||||
/** The editor's path/summary and actions go to the route's page header instead of a bar of
|
||||
* its own. Only the full-page editor sets it; embedders keep their own bar. */
|
||||
ownsPageHeader?: boolean
|
||||
}
|
||||
|
||||
@@ -20,15 +20,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import {
|
||||
ArrowUp,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
Settings,
|
||||
WandSparkles
|
||||
} from 'lucide-svelte'
|
||||
import { ArrowUp, Globe2, KeyRound, Pin, PinOff, PlugZap, Settings } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Badge } from '../common'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
@@ -46,6 +38,8 @@
|
||||
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
|
||||
import { USER_SETTINGS_HASH } from '../sidebar/settings'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { onboardingProfile } from '$lib/onboardingProfile'
|
||||
|
||||
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
|
||||
@@ -293,49 +287,54 @@
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showComposer}
|
||||
<!-- All that is left of the composer once dismissed: sits with the CLI/MCP row so the
|
||||
collapsed home page is one quiet line. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: WandSparkles }}
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
Build with AI
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- Not AI-related, so shown even when the composer is hidden. -->
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
|
||||
<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper,
|
||||
the hub, and the way back to the composer once it has been put away. -->
|
||||
<PageHeaderContent actions={homeMenu} />
|
||||
|
||||
{#snippet homeMenu()}
|
||||
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
|
||||
this page — all of them preferences or side trips, none of them the page's work. -->
|
||||
<DropdownV2
|
||||
placement="bottom-end"
|
||||
size="sm"
|
||||
items={[
|
||||
...(showComposer
|
||||
? [
|
||||
collapsed
|
||||
? {
|
||||
displayName: 'Pin session chat to the page',
|
||||
icon: Pin,
|
||||
action: () => setCollapsed(false)
|
||||
}
|
||||
: {
|
||||
displayName: 'Unpin session chat',
|
||||
icon: PinOff,
|
||||
action: () => setCollapsed(true)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'CLI / MCP',
|
||||
icon: PlugZap,
|
||||
action: () => homeConnectDrawer?.openDrawer?.()
|
||||
},
|
||||
...(!$userStore?.operator && HOME_SHOW_HUB
|
||||
? [
|
||||
{
|
||||
displayName: 'Hub',
|
||||
icon: Globe2,
|
||||
href: $hubBaseUrlStore,
|
||||
hrefTarget: '_blank' as const
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -25,6 +25,8 @@ export type PageHeaderItem = {
|
||||
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
|
||||
export type PageHeaderSection = {
|
||||
label: string
|
||||
/** Rendered in place of `label` when the name is itself a control, such as an editable title. */
|
||||
content?: Snippet
|
||||
/** Scopes the segment's picker, when the section maps to a kind of item. */
|
||||
kind?: WorkspaceItemKind
|
||||
}
|
||||
@@ -34,6 +36,19 @@ export type PageHeaderContent = {
|
||||
section?: PageHeaderSection
|
||||
/** Rendered at the right end of the bar — the page's own buttons. */
|
||||
actions?: 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. */
|
||||
actingWorkspaceId?: string
|
||||
/** False drops the band's bottom edge, for a page that draws its own first line. */
|
||||
barBorder?: boolean
|
||||
/** Width in px, at the right of the viewport, that this page owns from the top down — a
|
||||
* session's side panel. The band stops there instead of running over it, and the band floats
|
||||
* above the page rather than pushing it down, so that column starts at the top. */
|
||||
barRightInset?: number
|
||||
/** 'inline' when the route renders the bar itself — for a layout whose side panel must run to
|
||||
* the top of the viewport beside it, rather than starting under a full-width bar. */
|
||||
barPlacement?: 'layout' | 'inline'
|
||||
/** 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<any, any>
|
||||
@@ -46,15 +61,42 @@ export type PageHeaderContent = {
|
||||
// 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.
|
||||
// Registrations stack, and what the bar shows is their merge (see `content`). 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.
|
||||
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
export const pageHeader = {
|
||||
/**
|
||||
* The registrations merged, later ones winning field by field: a route sets the frame (where
|
||||
* the bar goes) and something nested in it — the active session, say — fills the breadcrumb
|
||||
* and the actions.
|
||||
*/
|
||||
get content(): PageHeaderContent | undefined {
|
||||
return entries[0]?.get()
|
||||
if (entries.length === 0) return undefined
|
||||
const merged: PageHeaderContent = {}
|
||||
for (const e of entries) {
|
||||
const c = e.get()
|
||||
if (c.item !== undefined) merged.item = c.item
|
||||
if (c.section !== undefined) merged.section = c.section
|
||||
if (c.contexts !== undefined) merged.contexts = c.contexts
|
||||
if (c.barPlacement !== undefined) merged.barPlacement = c.barPlacement
|
||||
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
if (c.barBorder !== undefined) merged.barBorder = c.barBorder
|
||||
}
|
||||
return merged
|
||||
},
|
||||
/**
|
||||
* Every registration's actions, in registration order: a page and something inside it can both
|
||||
* put buttons in the band, and each renders with the contexts its own registration named.
|
||||
*/
|
||||
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
|
||||
return entries.flatMap((e) => {
|
||||
const c = e.get()
|
||||
return c.actions ? [{ render: c.actions, contexts: c.contexts }] : []
|
||||
})
|
||||
},
|
||||
/** Registers on mount and returns the id to release on destroy. */
|
||||
register(get: () => PageHeaderContent): number {
|
||||
|
||||
@@ -285,6 +285,6 @@
|
||||
src={iframeSrc}
|
||||
sandbox={sandboxAttr}
|
||||
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
|
||||
class="w-full h-full min-h-screen bg-white border-none"
|
||||
class="w-full h-full bg-white border-none"
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'Workspace' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
class="w-full justify-center whitespace-nowrap"
|
||||
onActivate={onNavActivate}
|
||||
/>
|
||||
<ToggleButton
|
||||
@@ -80,7 +80,7 @@
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'AI Sessions (beta)' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
class="w-full justify-center whitespace-nowrap"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
|
||||
import SessionsBetaBanner from './SessionsBetaBanner.svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import { Button, NameIdTooltip } from '$lib/components/common'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
@@ -18,17 +19,12 @@
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowUpRight,
|
||||
EllipsisVertical,
|
||||
ExternalLink,
|
||||
MessageSquareDashed,
|
||||
Pencil,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import { type Item } from '$lib/utils'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
|
||||
import SessionChangesBar from './SessionChangesBar.svelte'
|
||||
import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte'
|
||||
@@ -53,17 +49,15 @@
|
||||
} from './sessionState.svelte'
|
||||
import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
|
||||
|
||||
// headerInset: extra left padding on the chat header so it clears a floating
|
||||
// control (the detached sidebar's handle) sitting at the screen's top-left.
|
||||
let {
|
||||
sessionId,
|
||||
headerInset = false
|
||||
headerInPage = false
|
||||
}: {
|
||||
sessionId: string
|
||||
headerInset?: boolean
|
||||
/** True for the session the page is showing: its header rides the page header bar. */
|
||||
headerInPage?: boolean
|
||||
} = $props()
|
||||
|
||||
// Parent keys by sessionId; this wrapper only mounts when the session exists.
|
||||
@@ -112,34 +106,9 @@
|
||||
})
|
||||
})
|
||||
|
||||
// The workspace the session acts on, shown in the header "Acting on" strip via the shared
|
||||
// WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets.
|
||||
const acting = $derived.by(() => {
|
||||
const wsId = session ? getEffectiveWorkspaceId(session) : undefined
|
||||
if (!wsId) return undefined
|
||||
const name = $userWorkspaces.find((w) => w.id === wsId)?.name ?? wsId
|
||||
return { targetId: wsId, name }
|
||||
})
|
||||
|
||||
// Ellipsis menu on the "Acting on" chip. Both entries are real links (so
|
||||
// modifier/middle clicks open a new tab); the `workspace` query param
|
||||
// points the navigation at the acting workspace — the layout applies it on
|
||||
// both full loads and client-side query changes. The trailing external-link
|
||||
// glyphs make the leave-the-session navigation explicit.
|
||||
const actingMenu = $derived<Item[]>([
|
||||
{
|
||||
displayName: 'Workspace settings',
|
||||
icon: Settings,
|
||||
href: `${base}/workspace_settings?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
},
|
||||
{
|
||||
displayName: 'Go to this workspace',
|
||||
icon: ArrowUpRight,
|
||||
href: `${base}/?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
}
|
||||
])
|
||||
// The workspace the session acts on. The page header's breadcrumb scopes itself to it, so a
|
||||
// session running in a fork reads as that fork rather than as the navigation workspace.
|
||||
const actingWorkspaceId = $derived(session ? getEffectiveWorkspaceId(session) : undefined)
|
||||
|
||||
// Load copilot config (models, providers) for the workspace the session acts
|
||||
// on, not the navigation workspace — a session deliberately leaves
|
||||
@@ -150,7 +119,7 @@
|
||||
// otherwise race to clobber the active chat's model config.
|
||||
$effect(() => {
|
||||
if (sessionState.currentSessionId !== sessionId) return
|
||||
const ws = acting?.targetId ?? $workspaceStore
|
||||
const ws = actingWorkspaceId ?? $workspaceStore
|
||||
if (ws) {
|
||||
loadCopilot(ws)
|
||||
}
|
||||
@@ -334,10 +303,6 @@
|
||||
'flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md'
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkHint()}
|
||||
<ExternalLink size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#if !session || !runtime}
|
||||
<div class="p-8 text-secondary text-sm">Session not found</div>
|
||||
{:else}
|
||||
@@ -411,95 +376,79 @@
|
||||
sessions have their own empty-state affordances above. -->
|
||||
{#snippet sessionEmptyHint()}{/snippet}
|
||||
|
||||
{#snippet sessionHeader()}
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-xs font-normal"
|
||||
inputClass="!text-xs !font-normal"
|
||||
/>
|
||||
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/snippet}
|
||||
|
||||
{#if headerInPage}
|
||||
<!-- The chat's own header row is the page header on this route: the session's name is its
|
||||
breadcrumb, and the menu is its action. -->
|
||||
<PageHeaderContent
|
||||
section={{ label: session.summary || session.name, content: sessionHeader }}
|
||||
{actingWorkspaceId}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- The wrapper contributes only the chat column; edited items are shown in the
|
||||
page's preview tabs (PreviewTabHost) beside it, not a second pane here. The
|
||||
single Pane fills 100% (no explicit `size`, so Splitpanes auto-distributes). -->
|
||||
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
|
||||
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
|
||||
<header
|
||||
class="flex flex-row items-center gap-1 {headerInset
|
||||
? 'pl-11'
|
||||
: 'pl-4'} pr-4 py-2 shrink-0"
|
||||
>
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-sm font-semibold"
|
||||
inputClass="!text-sm !font-semibold"
|
||||
/>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if acting && hasFirstUserMessage}
|
||||
<!-- "Acting on" context: workspace root (avatar + name) and, when the
|
||||
session runs in a fork, the fork name (accent pill) — with a button
|
||||
to jump into that workspace. Compact; sits right after the title.
|
||||
Hidden until the session has started — a new (un-sent) session shows
|
||||
the "Run in" picker (SessionWorkspaceBar) instead. -->
|
||||
<div class="flex items-center gap-1 min-w-0 text-2xs text-tertiary">
|
||||
<span class="shrink-0">Acting on</span>
|
||||
<!-- Hover reveals the workspace name + id + copy button (shared
|
||||
NameIdTooltip, same as the sidebar family picker), so the chip
|
||||
carries the copy affordance without an inline button. -->
|
||||
<NameIdTooltip name={acting.name} id={acting.targetId}>
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={acting.targetId}
|
||||
showChevron={false}
|
||||
interactive={false}
|
||||
disableTitle
|
||||
class="max-w-[16rem]"
|
||||
menuItems={actingMenu}
|
||||
/>
|
||||
</NameIdTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<Popover
|
||||
appearTimeout={0}
|
||||
disappearTimeout={0}
|
||||
class="w-full"
|
||||
class={compact ? 'w-auto' : 'w-full'}
|
||||
disablePopup={!isCollapsed}
|
||||
placement="right"
|
||||
>
|
||||
@@ -122,7 +122,8 @@
|
||||
{href}
|
||||
data-light-mode={lightMode}
|
||||
class={twMerge(
|
||||
'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full',
|
||||
'group flex items-center px-2 py-2 font-light rounded-md gap-2',
|
||||
compact ? 'w-auto' : 'w-full',
|
||||
sidebarClasses.hoverBg,
|
||||
'transition-all relative',
|
||||
sublabel ? 'h-10' : 'h-8',
|
||||
@@ -173,7 +174,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col text-left grow min-w-0">
|
||||
<div class="flex flex-col text-left min-w-0 {compact ? '' : 'grow'}">
|
||||
{#if !isCollapsed && label}
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -12,31 +12,25 @@
|
||||
} from '$lib/stores'
|
||||
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import { NAV_PAGES } from './navPages'
|
||||
import {
|
||||
BookOpen,
|
||||
ServerCog,
|
||||
Boxes,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Logs,
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Github,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Newspaper,
|
||||
Play,
|
||||
Route,
|
||||
Settings,
|
||||
UserCog,
|
||||
Users,
|
||||
Plus,
|
||||
Unplug,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Pyramid,
|
||||
MailIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
@@ -197,21 +191,16 @@
|
||||
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'
|
||||
)
|
||||
let mainMenuLinks = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Home',
|
||||
href: `${base}/`,
|
||||
icon: Home,
|
||||
// Labels, paths and icons come from the shared page table, so the nav and the page header's
|
||||
// breadcrumb always call a page the same thing. Everything else here is nav-only.
|
||||
const MAIN_MENU_EXTRAS: Record<string, { aiId: string; aiDescription: string; onclick?: () => void }> =
|
||||
{
|
||||
Home: {
|
||||
aiId: 'sidebar-menu-link-home',
|
||||
aiDescription:
|
||||
"Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
},
|
||||
{
|
||||
label: 'Runs',
|
||||
href: `${base}/runs`,
|
||||
icon: Play,
|
||||
disabled: !allows('runs'),
|
||||
Runs: {
|
||||
aiId: 'sidebar-menu-link-runs',
|
||||
aiDescription: 'Button to navigate to runs',
|
||||
onclick: () => {
|
||||
@@ -220,47 +209,28 @@
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
href: `${base}/variables`,
|
||||
icon: DollarSign,
|
||||
disabled: !allows('variables'),
|
||||
Variables: {
|
||||
aiId: 'sidebar-menu-link-variables',
|
||||
aiDescription: 'Button to navigate to variables'
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
href: `${base}/resources`,
|
||||
icon: Boxes,
|
||||
disabled: !allows('resources'),
|
||||
Resources: {
|
||||
aiId: 'sidebar-menu-link-resources',
|
||||
aiDescription: 'Button to navigate to resources'
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: `${base}/assets`,
|
||||
icon: Pyramid,
|
||||
disabled: !allows('assets'),
|
||||
aiId: 'sidebar-menu-link-assets',
|
||||
aiDescription: 'Button to navigate to assets'
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
icon: FolderOpen,
|
||||
disabled: !allows('folders'),
|
||||
aiId: 'sidebar-menu-link-folders',
|
||||
aiDescription: 'Button to navigate to folders'
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
href: `${base}/groups`,
|
||||
icon: Users,
|
||||
disabled: !allows('groups'),
|
||||
aiId: 'sidebar-menu-link-groups',
|
||||
aiDescription: 'Button to navigate to groups'
|
||||
}
|
||||
].filter((l) => !excludeMainLabels.includes(l.label))
|
||||
Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' },
|
||||
Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' },
|
||||
Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' }
|
||||
}
|
||||
let mainMenuLinks = $derived(
|
||||
NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS)
|
||||
.map((p) => ({
|
||||
label: p.label,
|
||||
href: p.path === '/' ? `${base}/` : `${base}${p.path}`,
|
||||
icon: p.icon,
|
||||
disabled: p.operatorKey ? !allows(p.operatorKey) : false,
|
||||
...MAIN_MENU_EXTRAS[p.label]
|
||||
}))
|
||||
.filter((l) => !excludeMainLabels.includes(l.label))
|
||||
)
|
||||
let defaultExtraTriggerLinks = $derived([
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
name={currentFamily?.name ?? $workspaceStore ?? ''}
|
||||
id={currentFamily?.id ?? $workspaceStore ?? ''}
|
||||
disablePopup={isCollapsed}
|
||||
class="block w-full"
|
||||
class={compact ? 'block' : 'block w-full'}
|
||||
>
|
||||
<MenuButton
|
||||
icon={Building}
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
|
||||
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
|
||||
// `compact` is the page header's variant: sized to its label on one line, rather than filling
|
||||
// a rail's width.
|
||||
let { isCollapsed = false, compact = false }: { isCollapsed?: boolean; compact?: boolean } =
|
||||
$props()
|
||||
|
||||
const effectiveId = $derived($workspaceStore ?? undefined)
|
||||
// Resolve once here and pass to the picker + trigger below, avoiding a duplicate lookup (see
|
||||
@@ -62,7 +65,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
|
||||
<div
|
||||
class="flex items-center min-w-0 {compact ? '' : 'px-2'} {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''} py-0.5 rounded-md"
|
||||
>
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={effectiveId}
|
||||
{forkableWorkspaces}
|
||||
@@ -70,7 +77,7 @@
|
||||
onRequestCreateFork={openForkModal}
|
||||
{settingsHref}
|
||||
{settingsLabel}
|
||||
class="min-w-0 w-full"
|
||||
class={compact ? 'min-w-0' : 'min-w-0 w-full'}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<WorkspaceScopeTrigger
|
||||
@@ -78,8 +85,8 @@
|
||||
{forkableWorkspaces}
|
||||
{isCollapsed}
|
||||
{rootLabel}
|
||||
wrap
|
||||
class="w-full"
|
||||
wrap={!compact}
|
||||
class={compact ? '' : 'w-full'}
|
||||
/>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Boxes,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
Home,
|
||||
HardHat,
|
||||
Play,
|
||||
Pyramid,
|
||||
Unplug,
|
||||
Users
|
||||
} from 'lucide-svelte'
|
||||
import type { OperatorPageKey } from './operatorRoutes'
|
||||
|
||||
/**
|
||||
* The workspace pages the sidebar links to, and the names the page header's breadcrumb gives them.
|
||||
* One table so a page cannot be called one thing in the nav and another in the header.
|
||||
*/
|
||||
export type NavPage = {
|
||||
label: string
|
||||
/** Path under `base`, matched against the current route by longest prefix. */
|
||||
path: string
|
||||
icon: any
|
||||
/** The `operator_settings` key that admits an operator, when the page has one. */
|
||||
operatorKey?: OperatorPageKey
|
||||
}
|
||||
|
||||
export const NAV_PAGES: NavPage[] = [
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' },
|
||||
{ label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' },
|
||||
{ label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' },
|
||||
{ label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' },
|
||||
{ label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' },
|
||||
{ label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' },
|
||||
{ label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' },
|
||||
{ label: 'Triggers', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
|
||||
{ label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' },
|
||||
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' }
|
||||
]
|
||||
|
||||
/**
|
||||
* The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route
|
||||
* the table does not name falls back to its first segment (`/service_logs` → "Service logs").
|
||||
*/
|
||||
export function navPageFor(pathname: string, base: string): NavPage | undefined {
|
||||
const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname
|
||||
const match = NAV_PAGES.filter(
|
||||
(p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/'))
|
||||
).sort((a, b) => b.path.length - a.path.length)[0]
|
||||
if (match) return match
|
||||
const first = path.split('/').filter(Boolean)[0]
|
||||
if (!first) return undefined
|
||||
const label = first.replace(/_/g, ' ')
|
||||
return {
|
||||
label: label.charAt(0).toUpperCase() + label.slice(1),
|
||||
path: `/${first}`,
|
||||
icon: undefined
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
|
||||
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
@@ -108,7 +109,6 @@
|
||||
import { sessionState } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte'
|
||||
import { currentWorkspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
|
||||
import WorkspaceScopeHeader from '$lib/components/sidebar/WorkspaceScopeHeader.svelte'
|
||||
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
|
||||
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
|
||||
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
|
||||
@@ -1011,13 +1011,9 @@
|
||||
{#snippet sidebarToggle()}
|
||||
{@const hidden = navDetached.val}
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<!-- Hidden, hovering slides the card in for a look; the click is what puts the sidebar
|
||||
back for good. -->
|
||||
<button
|
||||
data-nav-handle
|
||||
class="p-1.5 rounded hover:bg-surface-hover"
|
||||
class="p-1.5 rounded hover:bg-surface-hover flex-shrink-0"
|
||||
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
|
||||
onmouseenter={() => hidden && navHandleSlot.open()}
|
||||
onclick={() => setDetached(!hidden)}
|
||||
>
|
||||
{#if hidden}
|
||||
@@ -1184,13 +1180,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !sessionMode}
|
||||
<!-- Workspace scope (fork picker), under the switch. -->
|
||||
<div class="pb-1 w-52 {sessionsSwitchShown ? '' : 'pt-2'}">
|
||||
<WorkspaceScopeHeader isCollapsed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sessionMode}
|
||||
<!-- Session mode: the session list owns the rail.
|
||||
w-52 cap (matches the desktop sidebar width): the drawer is
|
||||
@@ -1262,8 +1251,15 @@
|
||||
<SidebarUsage isCollapsed={false} />
|
||||
</div>
|
||||
|
||||
<!-- Same footer slot the docked rail puts it in, so the control that hides the
|
||||
sidebar and the one that brings it back sit in the same place. -->
|
||||
<div class="px-4 pt-3 pb-3.5 w-52 flex items-center justify-between">
|
||||
{@render brandMark(false)}
|
||||
{#if detachedFloating}
|
||||
<div class="flex gap-3">
|
||||
{@render sidebarToggle()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
<div
|
||||
class="flex-shrink-0 px-2 h-12 flex items-center {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''}"
|
||||
class="flex-shrink-0 px-2 gap-1 flex {isCollapsed
|
||||
? 'flex-col items-center py-2'
|
||||
: 'h-12 items-center'}"
|
||||
>
|
||||
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !sessionMode}
|
||||
<!-- Workspace scope (fork picker), under the switch. -->
|
||||
<div class="pb-1 {sessionsSwitchShown ? '' : 'pt-2'}">
|
||||
<WorkspaceScopeHeader {isCollapsed} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1401,19 +1393,7 @@
|
||||
>
|
||||
{@render brandMark(isCollapsed)}
|
||||
<div class="flex {isCollapsed ? 'flex-col gap-3' : 'gap-3'}">
|
||||
{#if isCollapsed}
|
||||
<!-- p-2/-m-2 widens the hit area to ~32px without moving the icon. -->
|
||||
<Tooltip class="flex" placement="top" small>
|
||||
<button
|
||||
class="p-2 -m-2 rounded hover:bg-surface-hover"
|
||||
aria-label="Detach sidebar"
|
||||
onclick={() => setDetached(true)}
|
||||
>
|
||||
<PanelLeftDashed size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
</button>
|
||||
{#snippet text()}Detach sidebar{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{@render sidebarToggle()}
|
||||
{#if !$userStore?.operator}
|
||||
<button
|
||||
class="p-2 -m-2 rounded hover:bg-surface-hover"
|
||||
@@ -1532,10 +1512,30 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col h-full w-full">
|
||||
{#if !menuHidden && !devOnly}
|
||||
<!-- The band runs the full width, above the sidebar: the sidebar starts under it. -->
|
||||
<PageHeaderBar toggle={sidebarToggle} />
|
||||
<div class="flex flex-col h-full w-full relative">
|
||||
{#if !menuHidden && !devOnly && pageHeader.content?.barPlacement !== 'inline'}
|
||||
{@const rightInset = pageHeader.content?.barRightInset}
|
||||
{@const leftInset = useDrawer ? 0 : railWidth}
|
||||
{#if rightInset != null}
|
||||
<!-- A page owning the right edge from the top (a session's side panel) gets the band
|
||||
as an overlay that stops where that column starts, so the column can reach the
|
||||
top. The page pads its own content to clear it. -->
|
||||
<div
|
||||
class={classNames('absolute top-0 z-30', sidebarTransitionClass)}
|
||||
style:left="{leftInset}rem"
|
||||
style:right="{rightInset}px"
|
||||
>
|
||||
<PageHeaderBar />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- The band spans the content, beside the rail rather than above it. -->
|
||||
<div
|
||||
class={classNames('shrink-0', sidebarTransitionClass)}
|
||||
style:padding-left="{leftInset}rem"
|
||||
>
|
||||
<PageHeaderBar />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $enterpriseLicense && !menuHidden}
|
||||
<!-- Announcements are an EE feature, so the component never mounts on CE: no
|
||||
|
||||
@@ -552,6 +552,7 @@
|
||||
</div>
|
||||
{:else if renderEditor}
|
||||
<FlowBuilder
|
||||
ownsPageHeader
|
||||
onTakeLatest={draftBaseVersion
|
||||
? async (shown?: string) => {
|
||||
// The version the drawer showed as head; see /scripts/edit.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -55,7 +56,6 @@
|
||||
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
|
||||
import { previewTargetForSessionTarget } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
|
||||
import {
|
||||
artifactKey,
|
||||
itemDisplayName,
|
||||
@@ -406,6 +406,8 @@
|
||||
let emptyStateNewTabOpen = $state(false)
|
||||
|
||||
let fullscreen = $state(false)
|
||||
// Width of the preview panel, so the band can stop where it starts.
|
||||
let previewWidth = $state(0)
|
||||
// Fullscreen is page state, not per-session, so it outlives a session switch —
|
||||
// tell the incoming session's model, whose own collapsed flag it overrides, or
|
||||
// re-opening the item plainly on screen would be judged invisible and not flash.
|
||||
@@ -840,6 +842,30 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- The session showing fills the band's breadcrumb. The band is the layout's, but it stops
|
||||
where the preview panel starts so that panel can run from the top of the viewport. -->
|
||||
<PageHeaderContent
|
||||
barRightInset={previewCollapsed || fullscreen ? undefined : previewWidth}
|
||||
barBorder={false}
|
||||
actions={sessionPageActions}
|
||||
/>
|
||||
|
||||
{#snippet sessionPageActions()}
|
||||
{#if previewCollapsed && !fullscreen}
|
||||
<!-- Collapsed preview: the way back to the side panel, in the band with the session's own
|
||||
controls rather than floating over the chat. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
title="Open side panel"
|
||||
onclick={() => owner?.setCollapsed(false)}
|
||||
>
|
||||
Open side panel
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
{#if embedded}
|
||||
<!-- Rendered inside a preview iframe — opening the sessions UI here would
|
||||
@@ -900,7 +926,14 @@
|
||||
{#if !fullscreen}
|
||||
<!-- Chat column. Warm sessions stay mounted (stacked, visibility-toggled)
|
||||
so switching between them preserves chat scroll/draft state. -->
|
||||
<Pane bind:size={chatPaneSize} minSize={25} class="flex flex-col min-h-0">
|
||||
<!-- The band floats over this column's top strip while a panel is beside it, so the
|
||||
column starts below it. The offset belongs on the pane, not on the stack's
|
||||
container: the session stacks are `absolute inset-0` and would ignore padding. -->
|
||||
<Pane
|
||||
bind:size={chatPaneSize}
|
||||
minSize={25}
|
||||
class="flex flex-col min-h-0 {previewCollapsed || fullscreen ? '' : 'pt-10'}"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
{#each warmSessions as s (s.id)}
|
||||
<div
|
||||
@@ -909,7 +942,7 @@
|
||||
: 'z-0 opacity-0 pointer-events-none'}"
|
||||
aria-hidden={s.id !== activeSession?.id}
|
||||
>
|
||||
<SessionWrapper sessionId={s.id} headerInset={navDetached.val} />
|
||||
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -925,7 +958,11 @@
|
||||
maxSize={previewCollapsed ? 0 : 100}
|
||||
class="flex flex-col min-h-0"
|
||||
>
|
||||
<div class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}">
|
||||
|
||||
<div
|
||||
bind:clientWidth={previewWidth}
|
||||
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
|
||||
? ''
|
||||
@@ -1154,21 +1191,6 @@
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{#if previewCollapsed && !fullscreen}
|
||||
<!-- Collapsed preview: no rail — a floating launcher in the top-right to
|
||||
reopen the side panel. -->
|
||||
<div class="absolute top-2 right-3 z-50">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
title="Open side panel"
|
||||
onclick={() => owner?.setCollapsed(false)}
|
||||
>
|
||||
Open side panel
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if aiHiddenVerdict === undefined}
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-surface">
|
||||
|
||||
Reference in New Issue
Block a user