Merge remote-tracking branch 'origin/enable-operator-ai-chat-nav' into improve-data-pipelines

# Conflicts:
#	frontend/src/lib/components/SummaryPathDisplay.svelte
#	frontend/src/lib/components/sessions/SessionsBetaBanner.svelte
#	frontend/src/routes/(root)/(logged)/+layout.svelte
This commit is contained in:
Diego Imbert
2026-09-30 11:11:48 +02:00
60 changed files with 2528 additions and 1910 deletions
@@ -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
@@ -0,0 +1,18 @@
<!--
@component
Re-exposes contexts from one component tree inside another.
A snippet handed to the page header renders under the header, not under the page that wrote it, so
any component it creates looks its contexts up in the header's tree and finds nothing. The page
passes the entries its buttons need and this sets them again around the render.
-->
<script lang="ts">
import { setContext, type Snippet } from 'svelte'
let { contexts, children }: { contexts?: Map<any, any>; children: Snippet } = $props()
// setContext only takes effect during init, so this instance is keyed on `contexts` by callers.
if (contexts) for (const [key, value] of contexts) setContext(key, value)
</script>
{@render children()}
@@ -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 {
@@ -98,27 +99,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
@@ -145,7 +126,7 @@
}
</script>
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight">
<div class="max-w-full group px-2 py-0.5 leading-tight inline-block align-top">
<!-- Path row -->
{#if !hidePath}
<div class="flex items-center max-w-full text-2xs text-secondary font-mono">
@@ -162,9 +143,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' }
@@ -177,9 +158,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}
+162 -69
View File
@@ -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'
@@ -68,6 +69,7 @@
import FlowHistory from './flows/FlowHistory.svelte'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import EditableInput from './common/EditableInput.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
@@ -157,12 +159,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
@@ -1271,6 +1277,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)
@@ -1530,78 +1546,155 @@
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
<div bind:this={flowBuilderRoot} class="flex flex-col h-full">
<!-- Nav between steps-->
<div
bind:clientWidth={topbarWidth}
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader
? 'max-h-9'
: 'max-h-12'}"
>
<div class="flex flex-row items-center gap-2 min-w-0">
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
savedPath={initialPath}
onBehalfOfEmail={$savedOnBehalfOfEmail}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
{#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, summaryContent: flowSummary }}
actions={flowHeaderActions}
contexts={headerContexts}
/>
{:else}
<!-- Nav between steps-->
<div
bind:clientWidth={topbarWidth}
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader
? 'max-h-9'
: 'max-h-12'}"
>
<div class="flex flex-row items-center gap-2 min-w-0">
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
savedPath={initialPath}
onBehalfOfEmail={$savedOnBehalfOfEmail}
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{#if opWorkspace && indicatorPath !== undefined}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="flow"
path={indicatorPath}
draftOnly={newFlow}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
</div>
{/if}
{#if opWorkspace && indicatorPath !== undefined}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="flow"
path={indicatorPath}
draftOnly={newFlow}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
{/if}
</div>
<div class="flex flex-row gap-2 items-center shrink-0">
{#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
{/if}
</div>
<div class="flex flex-row gap-2 items-center shrink-0">
{#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}
<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}
/>
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
{loadingSave}
unifiedSize={headerBtnSize}
{dropdownItems}
/>
</div>
</div>
</div>
{/if}
{#snippet flowSummary()}
<!-- The summary stays editable where the editor's own bar had it: the breadcrumb
beside it is the path, which this editor renames from its settings tab. -->
<EditableInput
value={flowStore.val.summary ?? ''}
placeholder="Add a summary..."
editable={customUi?.topBar?.editableSummary != false}
commitOnInput
size="sm"
onSave={(v) => (flowStore.val.summary = v)}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
/>
{/snippet}
{#snippet flowHeaderActions()}
<!-- Saving state, the autosave toggle and other people's drafts: the editor's own
bar carries them beside the path, so the header has to carry them when it owns
the bar — without them a full-page editor saves with no word either way. -->
{#if opWorkspace && indicatorPath !== undefined}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="flow"
path={indicatorPath}
draftOnly={newFlow}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
{/if}
{#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,311 @@
<!--
@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, ChevronDown, 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 BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
import { findWorkspaceRoot } 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 { copyToClipboard, getContrastTextColor } from '$lib/utils'
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
let {
item,
section,
afterName,
actingWorkspaceId
}: {
item?: PageHeaderItem
section?: PageHeaderSection
/** What sits beside the page's name: a mark like a documentation tooltip, or a control
* that acts on the thing named. */
afterName?: Snippet
/** 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)
// In session mode the workspace is the session's scope, not a place to navigate to: the name
// says which workspace the chat acts on, and going home from it would leave the session.
const sessionMode = $derived(page.url.pathname.startsWith(`${base}/sessions`))
const homeHref = $derived(
actingWorkspaceId ? `${base}/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `${base}/`
)
// 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
/** The workspace disc: the same glyph as the rest, with a ring of its own around it. */
const DISC = 20
/** Width of the name beside the chevron, so the picker can hang from the part's own left edge. */
let nameWidth = $state(0)
let workspaceMenu: Menu | undefined = $state(undefined)
// Its own confirmation under the path rather than the global toast: the answer belongs where
// the click was, and a toast for something this small is a lot of furniture.
let pathCopied = $state(false)
let copiedReset: ReturnType<typeof setTimeout> | undefined
async function copyPath() {
if (!item?.path || !(await copyToClipboard(item.path, false))) return
pathCopied = true
clearTimeout(copiedReset)
copiedReset = setTimeout(() => (pathCopied = false), 1500)
}
const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? []))
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 inFork = $derived(!!currentWs?.parent_workspace_id)
// A session names the workspace it acts on beside its own title, so here the family name alone
// says where the user is browsing — the fork in this spot would read as a workspace switch.
const showFork = $derived(inFork && !actingWorkspaceId)
const familyName = $derived(family?.name ?? scopeId ?? '')
// The workspace the user actually stands in, which is the fork's own name inside a fork.
const scopeName = $derived(currentWs?.name ?? familyName)
// 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)
// A dev workspace wears its environment; the root wears "prod", so which environment the user
// is standing in is answered the same way whichever one it is.
const envBadge = $derived.by(() => {
if (actingWorkspaceId) return undefined
if (!inFork) return { text: 'prod', dev: false }
if (currentWs?.is_dev_workspace)
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
return undefined
})
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
// fork's own name inside, so the family's name is not painted as if it were the fork.
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
</script>
{#snippet workspaceDisc()}
<!-- The glyph is the same size as every other icon on the line; only the disc around it is
larger, by the ring it needs to read as a disc at all. -->
<svg width={DISC} height={DISC} viewBox="0 0 {DISC} {DISC}" class="flex-shrink-0">
<circle cx={DISC / 2} cy={DISC / 2} r={DISC / 2} fill={discColor} />
<foreignObject x={(DISC - ICON) / 2} y={(DISC - ICON) / 2} width={ICON} height={ICON}>
<Building size={ICON} style={glyphColor ? `color: ${glyphColor}` : undefined} />
</foreignObject>
</svg>
{/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 envBadgeMark()}
{#if envBadge}
<Badge
color={envBadge.dev ? 'dark-blue' : 'gray'}
small
class={envBadge.dev
? 'text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white'
: 'text-3xs px-1 py-0'}>{envBadge.text}</Badge
>
{/if}
{/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"
>
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<div class="flex items-center min-w-0">
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
the name to line its left edge up with the disc. -->
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
<svelte:element
this={sessionMode ? 'span' : 'a'}
href={sessionMode ? undefined : homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it is
part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span
class={twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
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}
>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
</svelte:element>
</div>
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<NavBreadcrumbTrigger
{trigger}
class="flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</NavBreadcrumbTrigger>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody item={menuItem} closeMenu={() => workspaceMenu?.close()} />
{/snippet}
</Menu>
{/snippet}
</Menubar>
</div>
{#if item || section || routePage}
{@render slash()}
{/if}
{#if item}
{#if item.path}
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
to know which item this is, and what they reach for to paste somewhere else. -->
<div class="relative flex items-center min-w-0">
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
pointer and the popup after the click are affordance enough. -->
<button
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
>
{#if scopeKind === 'u'}
{@render userIcon()}
{:else}
{@render folderIcon()}
{/if}
{#each pathLevels as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<span class="truncate">{level}</span>
{/each}
</button>
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
<span
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
role="status"
>
Path copied successfully
</span>
{/if}
</div>
{:else}
<span class="{SEGMENT} hover:bg-transparent" aria-current="page">
{@render itemKindIcon()}
<span class="truncate">{KIND_LABEL_LOWER[item.kind]}</span>
</span>
{/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>
{#if afterName}{@render afterName()}{/if}
{:else if routePage}
{@const RouteIcon = routePage.icon}
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
whole, so the workspace and fork names give way first when the bar is full. -->
<span class="{SEGMENT} shrink-0 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 afterName}{@render afterName()}{/if}
{/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>
@@ -16,7 +16,8 @@
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { switchWorkspace } from '$lib/storeUtils'
import { Badge, Button } from './common'
import { Button, ButtonType } from './common'
import { twMerge } from 'tailwind-merge'
import Popover from './meltComponents/Popover.svelte'
import Toggle from './Toggle.svelte'
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
@@ -83,28 +84,35 @@
? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}`
: 'Edits restricted'
)
// The Popover renders its trigger as a <button>, so this wears the design system's subtle
// button rather than containing one — a <Button> here would nest a button inside a button.
const triggerClass = twMerge(
'inline-flex items-center gap-1 rounded-md whitespace-nowrap transition-all',
ButtonType.VariantStyles.subtle,
ButtonType.UnifiedSizingClasses.sm,
ButtonType.UnifiedHeightClasses.sm,
ButtonType.UnifiedFontSizes.sm,
'text-xs'
)
</script>
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
class="inline-flex items-center"
class={triggerClass}
triggerAttrs={{ 'aria-label': badgeLabel }}
>
{#snippet trigger()}
<!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the
one Popover wraps its trigger in. -->
<Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer">
{#if bypassActive}
<ShieldOff class="h-3 w-3" />
{:else if canonicalDev}
<GitFork class="h-3 w-3" />
{:else}
<Lock class="h-3 w-3" />
{/if}
{badgeLabel}
</Badge>
{#if bypassActive}
<ShieldOff size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{:else if canonicalDev}
<GitFork size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{:else}
<Lock size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
{/if}
{badgeLabel}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 p-4 text-xs max-w-sm">
@@ -0,0 +1,119 @@
<!--
@component
The bar every route wears, above the page content and beside the sidebar: the sidebar toggle, the
workspace picker, then whatever the route registered — an item's breadcrumb and summary, or a
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 { PanelLeft, PanelTopDashed } from 'lucide-svelte'
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
let {
navHidden = false,
hideNavHandle = false,
onUnpin
}: {
navHidden?: boolean
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
* along with this band — two handles for one gesture would be one too many. */
hideNavHandle?: boolean
/** Sends the band back behind its handle, on a page that owns the viewport. */
onUnpin?: () => void
} = $props()
const content = $derived(pageHeader.content)
const actions = $derived(pageHeader.actions)
const item = $derived(content?.item)
const section = $derived(content?.section)
</script>
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
{#if navDetached.val && !navHidden && !hideNavHandle}
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
detaching it happened — a control that hides the thing it sits on cannot also be the
way to bring it back. 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()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Show sidebar"
onclick={() => navHandleSlot.open()}
>
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
</button>
</div>
{/if}
{#if onUnpin}
<!-- After the sidebar's handle: the sidebar is the outer thing, the band sits inside it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin the header from deployed apps"
title="Unpin the header from deployed apps"
onclick={() => onUnpin?.()}
>
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
toggle uses, so the two controls read as one idea. -->
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
controls, they are what should narrow, not the name of where the user is. It still gives
way rather than pushing them off the bar once there is nothing left to take.
An embed (`navHidden`) gets the page's controls without the trail that would offer to
navigate the host's workspace. -->
{#if !navHidden}
<div class="flex min-w-0 shrink-[0.1]">
<NavBreadcrumb
{item}
{section}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</div>
{/if}
{#if item && !navHidden && (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>
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 min-w-0">
{#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}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. -->
<div class="ml-auto flex items-center gap-2 min-w-0 pl-4">
{#each actions as entry, i (i)}
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,39 @@
<!--
@component
Fills the layout's page header from the route that mounts it: the breadcrumb (an item's path, or a
section name for a list) and the page's own buttons. Renders nothing itself.
Only a route page mounts this. An editor nested in a session pane or a drawer must not take over
the header, so nesting is decided by the caller rather than guessed from context.
-->
<script lang="ts">
import { onMount } from 'svelte'
import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte'
let {
item,
section,
afterName,
actions,
actionsOrder,
contexts,
actingWorkspaceId,
barRightInset,
fullBleed
}: PageHeaderContent = $props()
onMount(() => {
const id = pageHeader.register(() => ({
item,
section,
afterName,
actions,
actionsOrder,
contexts,
actingWorkspaceId,
barRightInset,
fullBleed
}))
return () => pageHeader.release(id)
})
</script>
+221 -218
View File
@@ -43,6 +43,7 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHref, RUNS_PATH } from '$lib/components/sessions/previewPaths'
import { twMerge } from 'tailwind-merge'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
@@ -581,6 +582,222 @@
let resolutionNote = $state('')
</script>
{#snippet runsHint()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
>
All past and schedule executions of scripts and flows, including previews. You only see your own
runs or runs of groups you belong to unless you are an admin.
</Tooltip>
{/snippet}
{#snippet headerActions()}
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
so without it the badge butts against the first filter. -->
<div class="flex items-center pr-2">
<RunsQueue
success={filters.val.status ?? null}
{queue_count}
{suspended_count}
onJobsWaiting={() => {
jobsFilter('waiting')
}}
onJobsSuspended={() => {
jobsFilter('suspended')
}}
small
/>
</div>
<!-- The two groups are the widest thing in the bar: they appear only once the bar is wide
enough to hold them next to the search field, below which the same filtering is reachable
from the search field's own `kind` and `status` chips. -->
<div class="hidden min-[1500px]:flex gap-2">
<ToggleButtonGroup
tabListClass="hidden min-[1500px]:flex"
bind:selected={
() => filters.val.job_kinds ?? 'runs',
(v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v))
}
>
{#snippet children({ item })}
<ToggleButton size="sm" value="all" label="All" {item} />
<ToggleButton
size="sm"
value="runs"
label="Runs"
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
{item}
/>
<ToggleButton
size="sm"
value="dependencies"
label="Deps"
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButtonMore
small
hideSelectedOption={innerWidth < smallScreenWidth}
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
}
]}
{item}
bind:selected={filters.val.job_kinds}
/>
{/snippet}
</ToggleButtonGroup>
<ToggleButtonGroup
bind:selected={
() => filters.val.status ?? 'all',
(v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v))
}
id="status"
>
{#snippet children({ item })}
<ToggleButton size="sm" value={'all'} label="All" {item} />
<ToggleButton
size="sm"
value={'running'}
tooltip="Running"
class="whitespace-nowrap"
icon={CirclePlay}
iconProps={{
class:
'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400'
}}
{item}
/>
<ToggleButton
size="sm"
value={'success'}
tooltip="Success"
class="whitespace-nowrap"
icon={CircleCheck}
iconProps={{
class: 'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300'
}}
{item}
/>
<ToggleButton
size="sm"
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={CircleAlert}
iconProps={{
class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300'
}}
{item}
/>
<ToggleButton
size="sm"
value={'canceled'}
tooltip="Canceled"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="gray"
{item}
/>
{#if filters.val.status == 'waiting'}
<ToggleButton
size="sm"
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="blue"
{item}
/>
{:else if filters.val.status == 'suspended'}
<ToggleButton
size="sm"
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="purple"
{item}
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
<div class="hidden xl:flex gap-2 items-center">
{#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'}
<div class="flex items-center gap-1" title="Show schedules">
<Toggle
size="xs"
id="show-schedules"
bind:checked={
() => filters.val.job_trigger_kind !== '!schedule',
(v) =>
v ? delete filters.val.job_trigger_kind : (filters.val.job_trigger_kind = '!schedule')
}
/>
<Calendar size={14} />
</div>
{/if}
<div class="flex items-center gap-1" title="Show future jobs">
<Toggle
size="xs"
id="show-future-jobs"
bind:checked={
() => filters.val.show_future_jobs !== false,
(v) => (v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false))
}
/>
<Clock size={14} />
</div>
</div>
<!-- The one control in the band that gives way: everything else keeps its width and the
search field takes what is left, down to a width that still shows a filter chip. -->
<FilterSearchbar
class={twMerge(
'relative flex-1 min-w-[8rem]',
Object.keys(filters.val).length <= 3 ? 'max-w-[20rem]' : 'max-w-[26rem]',
ButtonType.UnifiedMinHeightClasses.sm
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
autofocus
/>
<TimeframeSelect
unifiedSize="sm"
onClick={() => jobsLoader?.loadJobs(true)}
loading={jobsLoader?.loading}
items={runsTimeframes}
bind:value={_timeframe.val}
/>
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<OpenInSessionButton
source={{
page: () => pageHref(RUNS_PATH) + window.location.search,
workspaceId: $workspaceStore ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
{/snippet}
<ConfirmationModal
title={askingForConfirmation?.title ?? ''}
confirmationText={askingForConfirmation?.confirmBtnText ?? ''}
@@ -649,226 +866,12 @@
</div>
{:else}
<div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}>
<!-- Header and filters -->
<div id="runs-filters-bar" class="flex flex-row items-start w-full px-4 gap-3 py-4 flex-wrap">
<div class="flex flex-row items-center gap-6">
<h1
class={twMerge(
'!text-2xl font-semibold leading-6 tracking-tight',
$userStore?.operator ? 'pl-10' : ''
)}
>
Runs
</h1>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
>
All past and schedule executions of scripts and flows, including previews. You only see
your own runs or runs of groups you belong to unless you are an admin.
</Tooltip>
<!-- Queue -->
<RunsQueue
success={filters.val.status ?? null}
{queue_count}
{suspended_count}
onJobsWaiting={() => {
jobsFilter('waiting')
}}
onJobsSuspended={() => {
jobsFilter('suspended')
}}
small={innerWidth < smallScreenWidth}
/>
</div>
<div class="hidden xl:flex gap-2 ml-6">
<ToggleButtonGroup
tabListClass="hidden xl:flex"
bind:selected={
() => filters.val.job_kinds ?? 'runs',
(v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v))
}
>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton
value="runs"
label="Runs"
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
{item}
/>
<ToggleButton
value="dependencies"
label="Deps"
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButtonMore
hideSelectedOption={innerWidth < smallScreenWidth}
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
}
]}
{item}
bind:selected={filters.val.job_kinds}
/>
{/snippet}
</ToggleButtonGroup>
<ToggleButtonGroup
bind:selected={
() => filters.val.status ?? 'all',
(v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v))
}
id="status"
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
<ToggleButton
value={'running'}
tooltip="Running"
class="whitespace-nowrap"
icon={CirclePlay}
iconProps={{
class:
'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400'
}}
{item}
/>
<ToggleButton
value={'success'}
tooltip="Success"
class="whitespace-nowrap"
icon={CircleCheck}
iconProps={{
class:
'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300'
}}
{item}
/>
<ToggleButton
value={'failure'}
tooltip="Failure"
class="whitespace-nowrap"
icon={CircleAlert}
iconProps={{
class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300'
}}
{item}
/>
<ToggleButton
value={'canceled'}
tooltip="Canceled"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="gray"
{item}
/>
{#if filters.val.status == 'waiting'}
<ToggleButton
value={'waiting'}
tooltip="Waiting"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="blue"
{item}
/>
{:else if filters.val.status == 'suspended'}
<ToggleButton
value={'suspended'}
tooltip="Suspended"
class="whitespace-nowrap"
icon={Hourglass}
selectedColor="purple"
{item}
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
<div class="hidden xl:flex gap-2 items-center min-h-8 ml-2">
{#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'}
<div class="flex items-center gap-1" title="Show schedules">
<Toggle
size="xs"
color="nord"
id="show-schedules"
bind:checked={
() => filters.val.job_trigger_kind !== '!schedule',
(v) =>
v
? delete filters.val.job_trigger_kind
: (filters.val.job_trigger_kind = '!schedule')
}
/>
<Calendar size={14} />
</div>
{/if}
<div class="flex items-center gap-1" title="Show future jobs">
<Toggle
size="xs"
color="nord"
id="show-future-jobs"
bind:checked={
() => filters.val.show_future_jobs !== false,
(v) =>
v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false)
}
/>
<Clock size={14} />
</div>
</div>
<TimeframeSelect
wrapperClasses="ml-auto"
onClick={() => jobsLoader?.loadJobs(true)}
loading={jobsLoader?.loading}
items={runsTimeframes}
bind:value={_timeframe.val}
/>
<FilterSearchbar
class={twMerge(
'flex-1 relative min-w-[18rem]',
Object.keys(filters.val).length <= 3
? 'max-w-[20rem] 2xl:max-w-[28rem]'
: 'max-w-[34rem]',
ButtonType.UnifiedMinHeightClasses.md
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
autofocus
/>
<!-- The filters are shallow-routed, so the search has to come off
`window.location` at click time — `page.url` never sees them. Always the
canonical `/runs`: only that is a recognized preview page, and the
`/runs/<path>` route mirrors its path into `?path=` anyway. -->
<OpenInSessionButton
source={{
page: () => pageHref(RUNS_PATH) + window.location.search,
workspaceId: $workspaceStore ?? undefined
}}
btnProps={{ unifiedSize: 'md' }}
/>
</div>
<!-- Everything that used to sit in a row above the graph — the page's name, its queue, the
filters and the timeframe — is in the page header. -->
<PageHeaderContent afterName={runsHint} actions={headerActions} />
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
<div class="relative z-10 mb-2 flex gap-2">
<Tabs bind:selected={graph}>
<Tab value="RunChart" label="Duration" id="runs-chart-duration-tab" />
@@ -35,6 +35,8 @@
summary: string
labels: string[] | undefined
}) => string | undefined
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
}
let {
@@ -45,7 +47,8 @@
editable = false,
onSaved,
kind = 'flow',
saveOverride = undefined
saveOverride = undefined,
compact = false
}: Props = $props()
let editSummary = $state('')
@@ -128,14 +131,21 @@
>
{#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'}"
>
<span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
>
{#if !compact}
<span
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
>
{/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'}"
>
@@ -252,11 +262,13 @@
{/snippet}
</Popover>
{:else}
<div class="min-w-0 truncate flex flex-col px-2">
{#if !emptyString(summary)}
<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,18 @@
</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} actionsOrder={-100} />
{/if}
{#snippet draftsAction()}
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it. -->
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
<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}
@@ -0,0 +1,16 @@
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
// A deployed app is the page, not a page inside Windmill's chrome, so it opens with the header out
// of the way. Pinning brings the header back for good — one flag for every app rather than one per
// app: someone who wants the breadcrumb over a dashboard wants it over the next one too.
const pref = useLocalStorageValue<boolean>('app_header_pinned', false, 'boolean')
/** Whether the page header stays in flow on a deployed app's page. */
export const appHeaderPinned = {
get val(): boolean {
return pref.val === true
},
set val(pinned: boolean) {
pref.val = pinned
}
}
@@ -12,6 +12,8 @@
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 { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
@@ -124,30 +126,52 @@
})
</script>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
<!-- The line separating the app from the band exists only while the band does: unpinned there is
nothing above the app to separate it from. It goes on the app's wrapper rather than on the
frame, which the header-less public page shares — and as an inset shadow rather than a border,
which would take a pixel off an app sized to fill the page exactly and leave it scrolling. -->
<div
class="h-full {appHeaderPinned.val
? 'shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]'
: ''}"
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
</div>
{#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. -->
<!-- Edit sits with the app's name rather than at the far end of the bar: on a page whose header
is only there while hovered, the far end is a journey across the window. -->
<PageHeaderContent item={{ kind: 'app', path }} afterName={editAction} fullBleed />
{/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 } }
}
@@ -70,7 +70,14 @@
<div class="flex items-center">
<!-- The tooltip fixes a bug where the other tooltips won't disappear -->
<Tooltip disablePopup>
<DropdownV2 {btnText} enableFlyTransition {items} size={small ? 'sm' : 'md'} />
<!-- fixedHeight pins the wrapper at h-8, which overhangs a small group's 28px row. -->
<DropdownV2
{btnText}
enableFlyTransition
{items}
size={small ? 'sm' : 'md'}
fixedHeight={!small}
/>
</Tooltip>
</div>
</div>
@@ -5,7 +5,6 @@
import { AIBtnClasses } from './chat/AIButtonStyle'
import { prefersSessionHandoff } from './chat/global/gate'
import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
interface Props {
label?: string
initialInput?: string
@@ -16,7 +15,7 @@
// The label stays short ("Ask AI") for the search bar's inline row; the hover
// text is where "new AI session" fits.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
const handsOffToSession = $derived(prefersSessionHandoff())
export function onClick() {
// No item to preview here — this carries a question, not a target — so the
@@ -3,7 +3,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte'
import { zIndexes } from '$lib/zIndexes'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { chatState } from './sharedChatState.svelte'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { copilotInfo } from '$lib/aiStore'
@@ -47,8 +47,8 @@
}: Props = $props()
// The desktop rail is fixed-positioned, so the content is offset by a matching
// left padding (in rem, matching the rail). Mobile/operator/borderless: no rail.
let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth)
// left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail.
let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth)
$effect(() => {
chatState.dockedChatAvailable = !disableAi
@@ -87,7 +87,7 @@
<div
class={classNames(
'py-0.5 px-4 sm:px-4 shadow-sm max-w-7xl md:hidden justify-start flex',
noBorder || $userStore?.operator ? 'hidden' : ''
noBorder ? 'hidden' : ''
)}
>
<Button
@@ -2657,7 +2657,7 @@ function allowedTriggerKinds(): PageTriggerKind[] {
}
// Pages an operator may reach: exactly the ones enabled in the workspace's
// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so
// operator_settings, the same source the sidebar gates on. Operators are never admins, so
// workspace_settings is excluded whatever the settings say.
function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] {
const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings
@@ -29,10 +29,9 @@ export function isGlobalAiEnabled(): boolean {
* Whether an AI entry point hands off to a session instead of driving the docked
* chat. Deliberately the same condition as the root layout's `disableAi`, so a
* caller falling back on `false` always has a mounted pane to fall back to.
* Operators keep that pane (`/sessions` refuses them) until the operator chat ships.
*/
export function prefersSessionHandoff(isOperator: boolean | undefined): boolean {
return isGlobalAiEnabled() && !isOperator
export function prefersSessionHandoff(): boolean {
return isGlobalAiEnabled()
}
/** Clear the opt-out, then hard-reload so every gated site re-reads it. */
@@ -3,11 +3,10 @@
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext, tick } from 'svelte'
import { MediaQuery } from 'svelte/reactivity'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import type { TriggerContext } from '../triggers'
import type { Item } from '$lib/utils'
import { Bell, BellOff, Calendar } from 'lucide-svelte'
@@ -33,7 +32,10 @@
color?: 'red'
}
const { triggersCount, triggersState } = $state(getContext<TriggerContext>('TriggerContext'))
const triggerContext = getContext<TriggerContext>('TriggerContext')
const { triggersCount, triggersState } = $state(triggerContext)
// The buttons below render in the page header, away from this page's tree.
const headerContexts = new Map<any, any>([['TriggerContext', triggerContext]])
interface Props {
mainButtons?: MainButton[]
@@ -134,73 +136,73 @@
}
</script>
<div class="border-b">
<div class="mx-auto">
<div
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
{#snippet summaryContent()}
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={errorHandlerKind}
compact
/>
{/snippet}
{#snippet actions()}
{#if tag}
<Badge>tag: {tag}</Badge>
{/if}
{@render children?.()}
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
{@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="default"
unifiedSize="sm"
on:click={async () => {
dispatch('seeTriggers')
await tick()
triggersState.selectedTriggerIndex = primarySchedule
}}
>
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
<SummaryPathDisplay
{summary}
{path}
bind:labels
{inheritedLabels}
{onSaved}
kind={errorHandlerKind}
/>
</div>
{#if tag}
<Badge>tag: {tag}</Badge>
{/if}
{@render children?.()}
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
{@const primarySchedule = triggersState.triggers.findIndex(
(t) => t.isPrimary && !t.isDraft
)}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
dispatch('seeTriggers')
await tick()
triggersState.selectedTriggerIndex = primarySchedule
}}
>
{$triggersCount?.primary_schedule?.schedule ?? ''}
</Button>
{/if}
{@render trigger_badges?.()}
</div>
<div class="flex gap-1 items-center pr-4">
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
{/key}
{/if}
{#if wide.current}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
bind:errorHandlerMuted
/>
{/if}
{#each barButtons as btn (btn.label)}
{@const dropdownItems = dropdownItemsOf(btn)}
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
{dropdownItems}
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
btnClasses="flex items-center gap-1 whitespace-nowrap"
>
{btn.label}
</Button>
{/each}
</div>
</div>
</div>
</div>
{$triggersCount?.primary_schedule?.schedule ?? ''}
</Button>
{/if}
{@render trigger_badges?.()}
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
{/key}
{/if}
{#if wide.current}
<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"
>
{btn.label}
</Button>
{/each}
{/snippet}
<!-- The bar this page's header used to draw now belongs to the layout: the path becomes the
breadcrumb, the summary keeps its rename-and-labels popover, and everything else rides along
as the bar's actions. -->
<PageHeaderContent
item={{ kind: errorHandlerKind, path, summaryContent }}
{actions}
contexts={headerContexts}
/>
@@ -71,7 +71,9 @@
{#if useDesktopLayout}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full">
<!-- The band draws no edge of its own, so the separation from it is this page's first
line, above the run form and the detail panel. -->
<div class="grow min-h-0 w-full border-t">
<Splitpanes>
<Pane size={65} minSize={50}>
{@render form?.({ graphInline: true })}
@@ -102,7 +104,7 @@
{:else}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full flex flex-col">
<div class="grow min-h-0 w-full flex flex-col border-t">
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
classic scrollbars would spend a track under them, opening a band between the tabs
and the content. Wheel, trackpad and drag still scroll the strip. -->
@@ -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
}
@@ -40,14 +40,9 @@
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const { flowStore, selectionManager, pathStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
@@ -408,11 +403,7 @@
// contain. Hand it to a session opened on that step rather than the
// docked chat, which sessions leave unmounted. Sent on arrival: the
// user already said what they wanted in the description field.
if (
!sessionScopedManager &&
sessionOpen &&
prefersSessionHandoff(actingUser?.operator)
) {
if (!sessionScopedManager && sessionOpen && prefersSessionHandoff()) {
void openSourceInSession(sessionOpen, {
previewParams: { selected: detail.moduleId },
seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions),
@@ -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'
@@ -107,16 +101,14 @@
// unlocked, so the far commoner unlocked workspace never pops the composer in mid-load.
let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment'))
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
// The composer hands off to /sessions, so users opted out of the sessions beta don't get it
// (the prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
let showComposer = $derived(
prefersSessionHandoff($userStore?.operator) &&
!runOnlyWorkspace &&
!$copilotInfo.workspaceDisabled
prefersSessionHandoff() && !runOnlyWorkspace && !$copilotInfo.workspaceDisabled
)
// The hero's margins and centered column are for the full block. The lone button row left
// by a collapsed, operator, run-only or hidden-assistant view is a hint line and should
// by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should
// cost the page almost nothing: no top margin, and the content column's full width so it
// hugs the right edge instead of floating centered in empty space.
let hero = $derived(showComposer && !collapsed)
@@ -149,7 +141,7 @@
const FADE_MS = 600
// Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in.
// Only while the composer is shown — otherwise (operators) it would loop forever driving an
// Only while the composer is shown — otherwise it would loop forever driving an
// unrendered input — and not under reduced motion, where the first prompt simply stays put.
// The index lives outside the effect so re-showing the composer resumes the rotation from the
// prompt currently displayed rather than restarting it.
@@ -295,49 +287,56 @@
</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 to operators / 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. -->
<!-- Last in the bar whatever else the page registers: a menu of side trips belongs after the
buttons that act on what is on screen. -->
<PageHeaderContent actions={homeMenu} actionsOrder={100} />
{#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}
@@ -16,6 +16,10 @@
interface Props {
placement?: Placement
/** Inline style on the panel. A margin here shifts it after floating-ui has placed it,
* which is the only way to anchor it to a box wider than the trigger: melt's menubar
* menu ignores `positioning.offset`. */
contentStyle?: string
justifyEnd?: boolean
lightMode?: boolean
maxHeight?: number
@@ -45,6 +49,7 @@
let {
placement = 'right-start',
contentStyle = '',
justifyEnd = false,
lightMode = false,
maxHeight = 900,
@@ -221,6 +226,7 @@
onmouseenter={cancelPendingClose}
onmouseleave={scheduleClose}
transition:placementFly={{ duration: 100, placement }}
style={contentStyle}
class={twMerge(
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
// Default: scroll on the melt element. submenuSafe moves it to the inner
@@ -0,0 +1,109 @@
import type { Snippet } from 'svelte'
import type { WorkspaceItemKind } from './workspacePicker'
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
export type PageHeaderItem = {
kind: WorkspaceItemKind
/** Shown whole, and copied on click. */
path: string | undefined
summary?: string
/** Rendered in place of the plain summary, for a page whose summary is itself a control —
* a detail page's rename-and-labels popover, an editor's editable title. */
summaryContent?: Snippet
}
/** 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
}
export type PageHeaderContent = {
item?: PageHeaderItem
section?: PageHeaderSection
/** Rendered at the right end of the bar — the page's own buttons. */
actions?: Snippet
/** Orders these buttons among the other registrations', ascending; registration order breaks a
* tie. For a menu that must stay at the far end however many pages register before it. */
actionsOrder?: number
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
afterName?: 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
/** The page is the whole viewport — a deployed app. The band leaves the flow and waits behind
* a handle in the corner, until the viewer pins it. */
fullBleed?: 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
/** 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>
}
// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb
// and actions by registering here.
//
// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or
// summary the page edits reaches the bar through the layout's own dependency tracking, with no
// effect keeping two copies in step.
//
// Registrations stack, and 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 {
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.afterName !== undefined) merged.afterName = c.afterName
if (c.contexts !== undefined) merged.contexts = c.contexts
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
}
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, i) => {
const c = e.get()
return c.actions
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
: []
})
.sort((a, b) => a.order - b.order || a.i - b.i)
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++
entries = [...entries, { id, get }]
return id
},
release(id: number) {
entries = entries.filter((e) => e.id !== id)
}
}
@@ -286,6 +286,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}
@@ -266,7 +266,7 @@
const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs)
// Say where the button leads: the route hands this prompt to a fresh AI
// session for everyone who has one, and drives the docked chat for the rest.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
const handsOffToSession = $derived(prefersSessionHandoff())
const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled)
$effect(() => {
@@ -170,6 +170,9 @@ export function unsandboxedRawAppHtml(
<head>
<meta charset="UTF-8" />
<title>App</title>
<!-- Before the app's own stylesheet, so a bundle that wants a margin can still set one: the
browser default of 8px would otherwise frame every app with a gap it never asked for. -->
<style>html, body { margin: 0; }</style>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx).replace(/</g, '\\u003c') : 'undefined'};
@@ -28,7 +28,9 @@
{#if small}
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<!-- mr-2: the count badge hangs half outside this box, so the margin — not the row's
gap — is what keeps it off whatever sits to the right. -->
<div class="relative mr-2">
<ServerCog size={16} />
{#if queue_count && ($queue_count ?? 0) > 0}
<div
@@ -49,14 +51,11 @@
{/if}
{#if small}
<Popover
contentClasses="p-4"
openOnHover
debounceDelay={100}
disablePopup={!suspended_count || ($suspended_count ?? 0) <= 0}
>
<!-- The icon is on the bar whatever the count, so it answers a hover whatever the count —
a disabled trigger takes no pointer events and reads as a dead icon. -->
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
{#snippet trigger()}
<div class="relative">
<div class="relative mr-2">
<Hourglass size={16} />
<div
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-surface-secondary-inverse rounded-full text-primary-inverse text-2xs h-4 min-w-4 px-1"
@@ -69,7 +68,7 @@
{@render suspendedContent()}
{/snippet}
</Popover>
{:else}
{:else if suspended_count && ($suspended_count ?? 0) > 0}
{@render suspendedContent()}
{/if}
</div>
@@ -103,28 +102,28 @@
{/snippet}
{#snippet suspendedContent()}
{#if suspended_count && ($suspended_count ?? 0) > 0}
<RunOption label="Suspended">
{#snippet tooltip()}
Jobs waiting for an event or approval before being resumed
{/snippet}
<div
class={suspended_count && ($suspended_count ?? 0) > 0
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center'
: ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div
>
<div class="truncate text-2xs !text-secondary">
<Button unifiedSize="md" variant="subtle" on:click={() => onJobsSuspended?.()}>
{#if success == 'suspended'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</RunOption>
{/if}
<RunOption label="Suspended">
{#snippet tooltip()}
Jobs waiting for an event or approval before being resumed
{/snippet}
<div
class={suspended_count && ($suspended_count ?? 0) > 0
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center'
: ''}
>
{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
</div>
<div class="truncate text-2xs !text-secondary">
<Button unifiedSize="md" variant="subtle" on:click={() => onJobsSuspended?.()}>
{#if success == 'suspended'}
<div class="flex flex-row items-center gap-1">
Reset filter
<X size={12} />
</div>
{:else}
<ListFilterPlus size={14} />
{/if}
</Button>
</div>
</RunOption>
{/snippet}
@@ -77,7 +77,7 @@
<script lang="ts">
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
import { Button } from '../common'
import { Button, type ButtonType } from '../common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Popover from '../meltComponents/Popover.svelte'
@@ -94,9 +94,18 @@
value: Timeframe
wrapperClasses?: string
onClick?: () => void
/** Sizes the three buttons of the control itself; the presets inside the popover keep md. */
unifiedSize?: ButtonType.UnifiedSize
}
let { loading = false, onClick, items, value = $bindable(), wrapperClasses }: Props = $props()
let {
loading = false,
onClick,
items,
value = $bindable(),
wrapperClasses,
unifiedSize = 'md'
}: Props = $props()
let isOpen = $state(false)
@@ -141,7 +150,7 @@
<div class="relative flex {wrapperClasses}">
<Button
unifiedSize="md"
{unifiedSize}
wrapperClasses="flex-1"
btnClasses="!rounded-r-none whitespace-nowrap"
onClick={() => onClick?.()}
@@ -154,7 +163,7 @@
{#if value.type === 'manual'}
<Button
btnClasses="!rounded-none border-l-0"
unifiedSize="md"
{unifiedSize}
onClick={() => (value = { ...items[0] })}
>
Reset
@@ -168,7 +177,7 @@
>
{#snippet trigger()}
<Button
unifiedSize="md"
{unifiedSize}
iconOnly
btnClasses="!rounded-l-none border-l-0"
endIcon={{ icon: CalendarIcon }}
@@ -43,10 +43,6 @@
import { copilotInfo } from '$lib/aiStore'
import { sendUserToast } from '$lib/toast'
import { openSourceInSession } from './sessionSwitch.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
source,
@@ -69,7 +65,7 @@
* says that clicking it leaves for a session. */
tooltip?: string
/** Rendered instead when the caller keeps a docked chat to drive — an
* opted-out user or an operator (typically the editor's inline-chat
* opted-out user (typically the editor's inline-chat
* toggle). Never rendered inside the session panel. */
fallback?: Snippet
} = $props()
@@ -80,13 +76,8 @@
// SessionEditorTarget / the session wrapper); iframe preview tabs are not
// the top window.
const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top)
// prefersSessionHandoff carries the operator clause: the sessions page refuses
// them, so an entry point on a page they can reach (Runs, the trigger lists)
// would only route them into that refusal.
const show = $derived(
!inSessionPanel &&
!!(source?.target || source?.page) &&
prefersSessionHandoff(actingUser?.operator)
!inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff()
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
@@ -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>
@@ -3,7 +3,9 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import { Button, NameIdTooltip } from '$lib/components/common'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.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'
@@ -17,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 +50,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 collapsed-rail launcher) 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 +107,12 @@
})
})
// 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)
const actingIsFork = $derived(
!!actingWorkspaceId && workspaceIsFork(actingWorkspaceId, $userWorkspaces)
)
// Load copilot config (models, providers) for the workspace the session acts
// on, not the navigation workspace — a session deliberately leaves
@@ -150,7 +123,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 +307,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 +380,93 @@
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>
{#if actingIsFork}
<!-- Only a fork earns this: on the root the breadcrumb already names the workspace the
chat writes to, and repeating it says nothing. The fork is the case worth calling
out, and it belongs with the session's name rather than in the breadcrumb, where it
would read as if the user had switched workspace. -->
<span class="shrink-0 text-2xs text-tertiary pl-1">Acting on</span>
<WorkspaceScopeTrigger
workspaceId={actingWorkspaceId}
showChevron={false}
interactive={false}
class="min-w-0"
/>
{/if}
{/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 ?? 'Untitled session', 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}
@@ -54,6 +54,8 @@
// Classes for the label line only — `class` reaches the button, the label and the
// sublabel alike, which is the wrong tool for colouring one line of the two.
labelClass?: string | undefined
// Draw the colour disc at 20px rather than 26px, for denser rows such as the page header.
compact?: boolean
}
let {
@@ -79,7 +81,8 @@
emphasizeLabel = false,
disableTitle = false,
ariaLabel = undefined,
labelClass = undefined
labelClass = undefined,
compact = false
}: Props = $props()
let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
@@ -94,7 +97,7 @@
<Popover
appearTimeout={0}
disappearTimeout={0}
class="w-full"
class={compact ? 'w-auto' : 'w-full'}
disablePopup={!isCollapsed}
placement="right"
>
@@ -119,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',
@@ -134,11 +138,23 @@
{#if icon}
{@const SvelteComponent = icon}
{#if color}
<svg width="26" height="26" viewBox="0 0 26 26" class="flex-shrink-0 -ml-[5px]">
<circle cx="13" cy="13" r="13" fill={color} />
<foreignObject x="5" y="5" width="16" height="16">
{@const disc = compact ? 20 : 26}
{@const glyph = compact ? 12 : 16}
<svg
width={disc}
height={disc}
viewBox="0 0 {disc} {disc}"
class="flex-shrink-0 {compact ? '-ml-[3px]' : '-ml-[5px]'}"
>
<circle cx={disc / 2} cy={disc / 2} r={disc / 2} fill={color} />
<foreignObject
x={(disc - glyph) / 2}
y={(disc - glyph) / 2}
width={glyph}
height={glyph}
>
<SvelteComponent
size={16}
size={glyph}
class={twMerge(sidebarClasses.iconText, 'transition-colors', iconClasses)}
{...iconProps}
/>
@@ -158,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(
@@ -1,337 +0,0 @@
<script lang="ts">
import {
Home,
Menu as MenuIcon,
Play,
Settings,
LogOut,
Moon,
Sun,
Code2,
LayoutDashboard,
Calendar,
ServerCog,
Table2,
GraduationCap
} from 'lucide-svelte'
import SwitchWorkspaceSubmenu from './SwitchWorkspaceSubmenu.svelte'
import {
OPERATOR_MAIN_LINKS,
OPERATOR_SECONDARY_LINKS,
OPERATOR_TRIGGER_LINKS,
type OperatorMenuLink,
type OperatorTriggerLink
} from './operatorRoutes'
import { base } from '$lib/base'
import { page } from '$app/state'
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { Plus } from 'lucide-svelte'
import {
enterpriseLicense,
superadmin,
usedTriggerKinds,
userWorkspaces,
workspaceColor,
workspaceStore
} from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { getContrastTextColor } from '$lib/utils'
import { USER_SETTINGS_HASH } from './settings'
import { logout } from '$lib/logoutKit'
import DarkModeObserver from '../DarkModeObserver.svelte'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
import MenuLink from './MenuLink.svelte'
import type { FavoriteKind } from './FavoriteMenu.svelte'
let darkMode: boolean = $state(false)
let showExtraTriggers = $state(false)
// The menu opens on hover, so this doubles as "the pointer is on the button":
// it slides the workspace name out beside the icon, and keeps it out once a
// click has pinned the menu open and the pointer has moved away.
let menuOpen = $state(false)
interface Props {
isCollapsed?: boolean
favoriteLinks?: {
label: string
href: string
kind: FavoriteKind
}[]
}
let { isCollapsed = false, favoriteLinks = [] }: Props = $props()
const MAIN_LINK_ICONS: Record<string, any> = { home: Home, runs: Play, schedules: Calendar }
let activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
let workspaceName = $derived(activeWorkspace?.name ?? $workspaceStore ?? '')
// Home is where an operator lands and gets their bearings, so the name stays out there
// rather than waiting for a hover. Everywhere else the button is the page's own chrome
// and stays out of the way until asked.
let showWorkspaceName = $derived(menuOpen || page.url.pathname === `${base}/`)
// Most workspaces never get a colour (it is only ever set explicitly), and this button
// floats over page content with nothing else behind it — so the disc falls back to a
// neutral rather than vanishing, keeping the glyph on a ground of its own everywhere.
// A token rather than a hex so it follows the theme; getContrastTextColor only parses
// hex, so it returns undefined here and the glyph keeps its themed `text-hint`.
const NEUTRAL_DISC = 'rgb(var(--color-surface-sunken))'
let mainMenuLinks = $derived(
OPERATOR_MAIN_LINKS.map((link) => ({ ...link, icon: MAIN_LINK_ICONS[link.id] })).filter(
(link) => link.id === 'home' || filterLink(link)
)
)
function filterLink(link: OperatorMenuLink) {
if (!$userWorkspaces || !$workspaceStore) return false
return activeWorkspace?.operator_settings?.[link.id] === true
}
let secondMenuLinks: OperatorMenuLink[] = $derived(OPERATOR_SECONDARY_LINKS.filter(filterLink))
let allTriggerLinks: OperatorTriggerLink[] = $derived(OPERATOR_TRIGGER_LINKS.filter(filterLink))
let secondMenuTriggerLinks = $derived(
allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind))
)
let extraTriggerLinks = $derived(
allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind))
)
</script>
<Menubar>
{#snippet children({ createMenu })}
<Menu
{createMenu}
placement="bottom-start"
openOnHover
usePointerDownOutside
submenuSafe
bind:open={menuOpen}
on:close={() => (showExtraTriggers = false)}
>
{#snippet triggr({ trigger, pinned })}
{@const iconColor = getContrastTextColor($workspaceColor)}
<!-- Ground for the expanded state only: the button floats over page content and its
hover tint is translucent, so the name would otherwise sit on whatever is
underneath. Icon-only needs none — the disc is always drawn. -->
<div class="flex rounded-md {showWorkspaceName ? 'bg-surface' : ''}">
<MenuButton
class="!text-xs"
buttonClass={twMerge(
'!pl-3.5 !pr-2 !w-auto',
// A hover-opened menu leaves the button plain once the pointer moves on;
// keeping the tint is what tells you the click pinned it.
pinned ? sidebarClasses.selectedBg : ''
)}
icon={MenuIcon}
isCollapsed={false}
lightMode
color={$workspaceColor ?? NEUTRAL_DISC}
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={showWorkspaceName ? workspaceName : undefined}
ariaLabel={workspaceName ? `Menu — ${workspaceName}` : 'Menu'}
{trigger}
/>
</div>
{/snippet}
{#snippet children({ item, builders })}
<div class="w-full max-w-full">
{#each favoriteLinks ?? [] as favorite (favorite.href)}
<MenuItem
href={favorite.href}
{item}
class={twMerge(
'w-full inline-flex flex-row px-2 py-2',
'data-[highlighted]:bg-surface-hover'
)}
>
<span class="center-center">
{#if favorite.kind == 'script'}
<Code2 size={16} />
{:else if favorite.kind == 'flow'}
<BarsStaggered size={16} />
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
<LayoutDashboard size={16} />
{:else if favorite.kind == 'asset'}
<Table2 size={16} />
{/if}
</span>
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
{favorite.label}
</span>
</MenuItem>
{/each}
</div>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
{/each}
<div class="divide-y" role="none">
<div role="none">
<MenuItem
href={USER_SETTINGS_HASH}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2',
sidebarClasses.text,
'transition-colors',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
lightMode
{item}
>
<Settings size={14} />
Account settings
</MenuItem>
<MenuItem
href="{base}/?{TOUR_PARAM}={TOUR_PARAM_VALUE}"
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2',
sidebarClasses.text,
'transition-colors',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
lightMode
{item}
>
<GraduationCap size={14} />
Take the tour
</MenuItem>
</div>
<div role="none">
<MenuItem
onClick={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
lightMode
class={twMerge(
'w-full flex gap-3.5 px-2 py-2',
'transition-colors',
sidebarClasses.text,
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{#if darkMode}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
Switch theme
</MenuItem>
<SwitchWorkspaceSubmenu {builders} {item} />
{#if $superadmin}
<MenuItem
href="#superadmin-settings"
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 ',
'text-secondary text-xs',
'cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<ServerCog size={14} />
Instance settings
</MenuItem>
{/if}
<MenuItem
onClick={() => logout()}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
'text-primary text-xs',
'cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<LogOut size={14} />
Sign out
</MenuItem>
</div>
<div role="none">
{#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])}
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{menuLink.label}
</MenuItem>
{/each}
{/snippet}
{#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length}
<div class="divide-y">
{#if secondMenuLinks.length}<div
>{@render renderSecondMenuLinks(secondMenuLinks)}</div
>{/if}
{#if secondMenuTriggerLinks.length}<div
>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div
>{/if}
{#if extraTriggerLinks.length}<div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="none"
onclickcapture={(e) => {
// This row expands the list below it instead of acting on the selection, and
// melt keeps the menu open only for a click it sees as defaultPrevented.
// Svelte delegates onclick to the root, which runs after melt's own listener,
// and a capture listener on the item itself would be ordered only by
// registration, so an ancestor's capture phase is what reliably wins.
e.preventDefault()
showExtraTriggers = !showExtraTriggers
}}
>
<MenuItem
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full text-secondary text-2xs cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<Plus size={12} />
<span class="text-2xs">More triggers</span>
</MenuItem>
</div>
{#if showExtraTriggers}
{#each extraTriggerLinks as menuLink (menuLink.href)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 pl-6 text-tertiary text-2xs cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{menuLink.label}
</MenuItem>
{/each}
{/if}
</div>{/if}
</div>
{/if}
{#if $enterpriseLicense}
<MultiplayerMenu />
{/if}
</div>
</div>
{/snippet}
</Menu>
{/snippet}
</Menubar>
<DarkModeObserver bind:darkMode />
@@ -18,7 +18,8 @@
Crown,
Gauge,
Trash2,
KeyRound
KeyRound,
GraduationCap
} from 'lucide-svelte'
import { base } from '$app/paths'
import { goto } from '$lib/navigation'
@@ -38,6 +39,8 @@
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
import { accountSetup } from './accountSetup.svelte'
import { EXECUTIONS_HINT } from './executionsHint'
import { sidebarPageAllowed } from './operatorRoutes'
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
import {
userWorkspaces,
workspaceStore,
@@ -248,6 +251,15 @@
icon: Settings,
action: () => goto(USER_SETTINGS_HASH)
},
...($userStore?.operator
? [
{
displayName: 'Take the tour',
icon: GraduationCap,
action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`)
}
]
: []),
...(cloudHosted && $isPremiumStore === false
? [
{
@@ -307,6 +319,7 @@
label="Workers"
href="{base}/workers"
icon={ServerCog}
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')}
{isCollapsed}
aiId="sidebar-menu-link-workers"
aiDescription="Button to navigate to workers"
@@ -352,7 +365,7 @@
label="Audit logs"
href="{base}/audit_logs"
icon={Eye}
disabled={$userStore?.operator}
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')}
{isCollapsed}
aiId="sidebar-menu-link-audit-logs"
aiDescription="Button to navigate to audit logs"
@@ -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
@@ -72,6 +66,7 @@
import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte'
import AzureIcon from '../icons/triggers/AzureIcon.svelte'
import { leaveCurrentWorkspace } from './leaveWorkspace'
import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes'
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
@@ -188,23 +183,24 @@
return count
}
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
function allows(key: OperatorPageKey) {
return sidebarPageAllowed($userStore?.operator, currentWs, key)
}
const itemClass = twMerge(
'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
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,
Runs: {
aiId: 'sidebar-menu-link-runs',
aiDescription: 'Button to navigate to runs',
onclick: () => {
@@ -213,53 +209,35 @@
}, 100)
}
},
{
label: 'Variables',
href: `${base}/variables`,
icon: DollarSign,
disabled: $userStore?.operator,
Variables: {
aiId: 'sidebar-menu-link-variables',
aiDescription: 'Button to navigate to variables'
},
{
label: 'Resources',
href: `${base}/resources`,
icon: Boxes,
disabled: $userStore?.operator,
Resources: {
aiId: 'sidebar-menu-link-resources',
aiDescription: 'Button to navigate to resources'
},
{
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
{
label: 'Folders',
href: `${base}/folders`,
icon: FolderOpen,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-folders',
aiDescription: 'Button to navigate to folders'
},
{
label: 'Groups',
href: `${base}/groups`,
icon: Users,
disabled: $userStore?.operator,
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([
{
label: 'HTTP',
href: '/routes',
icon: Route,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'http',
aiId: 'sidebar-menu-link-http',
aiDescription: 'Button to navigate to HTTP routes'
@@ -268,7 +246,7 @@
label: 'WebSockets',
href: '/websocket_triggers',
icon: Unplug,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'ws',
aiId: 'sidebar-menu-link-ws',
aiDescription: 'Button to navigate to websocket triggers'
@@ -277,7 +255,7 @@
label: 'Postgres',
href: '/postgres_triggers',
icon: Database,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'postgres',
aiId: 'sidebar-menu-link-postgres',
aiDescription: 'Button to navigate to Postgres triggers'
@@ -286,7 +264,7 @@
label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/kafka_triggers',
icon: KafkaIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
disabled: !allows('triggers') || !$enterpriseLicense,
kind: 'kafka',
aiId: 'sidebar-menu-link-kafka',
aiDescription: 'Button to navigate to Kafka triggers'
@@ -295,7 +273,7 @@
label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/nats_triggers',
icon: NatsIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
disabled: !allows('triggers') || !$enterpriseLicense,
kind: 'nats',
aiId: 'sidebar-menu-link-nats',
aiDescription: 'Button to navigate to NATS triggers'
@@ -304,7 +282,7 @@
label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/sqs_triggers',
icon: AwsIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
disabled: !allows('triggers') || !$enterpriseLicense,
kind: 'sqs',
aiId: 'sidebar-menu-link-sqs',
aiDescription: 'Button to navigate to SQS triggers'
@@ -313,7 +291,7 @@
label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/gcp_triggers',
icon: GoogleCloudIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
disabled: !allows('triggers') || !$enterpriseLicense,
kind: 'gcp',
aiId: 'sidebar-menu-link-gcp',
aiDescription: 'Button to navigate to GCP Pub/Sub triggers'
@@ -322,7 +300,7 @@
label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/azure_triggers',
icon: AzureIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
disabled: !allows('triggers') || !$enterpriseLicense,
kind: 'azure',
aiId: 'sidebar-menu-link-azure',
aiDescription: 'Button to navigate to Azure Event Grid triggers'
@@ -331,7 +309,7 @@
label: 'MQTT',
href: '/mqtt_triggers',
icon: MqttIcon,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'mqtt',
aiId: 'sidebar-menu-link-mqtt',
aiDescription: 'Button to navigate to MQTT triggers'
@@ -340,7 +318,7 @@
label: 'AMQP',
href: '/amqp_triggers',
icon: AmqpIcon,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'amqp',
aiId: 'sidebar-menu-link-amqp',
aiDescription: 'Button to navigate to AMQP triggers'
@@ -349,7 +327,7 @@
label: 'Email',
href: '/email_triggers',
icon: MailIcon,
disabled: $userStore?.operator,
disabled: !allows('triggers'),
kind: 'email',
aiId: 'sidebar-menu-link-email',
aiDescription: 'Button to navigate to Email triggers'
@@ -375,7 +353,7 @@
label: 'Schedules',
href: `${base}/schedules`,
icon: Calendar,
disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator,
disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'),
aiId: 'sidebar-menu-link-schedules',
aiDescription: 'Button to navigate to schedules'
},
@@ -390,7 +368,6 @@
)
// Admins, superadmins, and fork creators reach workspace settings (the latter
// for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user).
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
)
@@ -460,7 +437,7 @@
label: 'Workers',
href: `${base}/workers`,
icon: ServerCog,
disabled: $userStore?.operator,
disabled: !allows('workers'),
aiId: 'sidebar-menu-link-workers',
aiDescription: 'Button to navigate to workers'
},
@@ -509,7 +486,7 @@
label: 'Audit logs',
href: `${base}/audit_logs`,
icon: Eye,
disabled: $userStore?.operator,
disabled: !allows('audit_logs'),
aiId: 'sidebar-menu-link-audit-logs',
aiDescription: 'Button to navigate to audit logs'
}
@@ -551,7 +528,7 @@
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
{#if extraTriggerLinks.length > 0 && allows('triggers')}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
@@ -1,68 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
import { melt } from '@melt-ui/svelte'
import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte'
import { Building, ChevronRight } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { sidebarClasses } from './MenuButton.svelte'
import WorkspacePickerBody from './WorkspacePickerBody.svelte'
import { MenuItem } from '$lib/components/meltComponents'
import { clearWorkspaceFromStorage } from '$lib/stores'
import { base } from '$lib/base'
interface Props {
// Submenu builders from the enclosing melt Menu — createSubmenu must be
// called against this specific menu instance (same pattern as DropdownSubmenuItem).
builders: MenubarMenuBuilders
// Melt uses the parent menu's item builder for submenu rows too, so the
// picker body's MenuItems work here unchanged.
item: MenubarMenuElements['item']
}
let { builders, item }: Props = $props()
const {
elements: { subTrigger, subMenu },
states: { subOpen }
} = untrack(() => builders).createSubmenu()
</script>
<button
use:melt={$subTrigger}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
sidebarClasses.text,
'transition-colors',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
>
<Building size={14} class="shrink-0" />
<span class="grow text-left">Switch workspace</span>
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $subOpen}
<div
use:melt={$subMenu}
class="z-[6001] w-56 bg-surface border rounded-md shadow-md focus:outline-none"
>
<WorkspacePickerBody {item} strictWorkspaceSelect />
<!-- The picker's list scrolls inside itself, so this sits below it and stays reachable
however many workspaces the operator belongs to. -->
<div class="border-t border-border-light" role="none">
<MenuItem
href="{base}/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
class={twMerge(
'w-full flex flex-row gap-2 px-4 py-2 font-normal',
sidebarClasses.text,
'hover:bg-surface-hover hover:text-primary',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
All workspaces
</MenuItem>
</div>
</div>
{/if}
@@ -14,9 +14,19 @@
createMenu: MenubarBuilders['createMenu']
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
strictWorkspaceSelect?: boolean
// Denser trigger for the page header bar: a smaller disc and a lighter name, so the
// workspace reads as the scope of the breadcrumb beside it rather than a heading.
compact?: boolean
}
let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props()
let {
isCollapsed = false,
createMenu,
strictWorkspaceSelect = false,
compact = false
}: Props = $props()
let menu: Menu | undefined = $state(undefined)
// The active workspace's family root — shown in the trigger so a forked active workspace still
// surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly
@@ -29,7 +39,7 @@
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
</script>
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
<Menu bind:this={menu} {createMenu} usePointerDownOutside placement="bottom-start">
{#snippet triggr({ trigger })}
<!-- Family header reflects the family (root) color, not the active
workspace's — switching into a fork must not recolor it. -->
@@ -41,7 +51,7 @@
name={currentFamily?.name ?? $workspaceStore ?? ''}
id={currentFamily?.id ?? $workspaceStore ?? ''}
disablePopup={isCollapsed}
class="block w-full"
class={compact ? 'block' : 'block w-full'}
>
<MenuButton
icon={Building}
@@ -53,8 +63,10 @@
{isCollapsed}
color={familyColor}
showChevron
emphasizeLabel
emphasizeLabel={!compact}
disableTitle
{compact}
labelClass={compact ? 'wm-workspace-name text-primary font-medium' : 'wm-workspace-name'}
{trigger}
/>
</NameIdTooltip>
@@ -63,6 +75,11 @@
{#snippet children({ item })}
<!-- The only strict caller is a standalone page (svix webhook creation) that owns its
own navigation, so there a switch must leave the page where it is. -->
<WorkspacePickerBody {item} {strictWorkspaceSelect} keepPageOnSwitch={strictWorkspaceSelect} />
<WorkspacePickerBody
{item}
{strictWorkspaceSelect}
keepPageOnSwitch={strictWorkspaceSelect}
closeMenu={() => menu?.close()}
/>
{/snippet}
</Menu>
@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import { workspaceMenuHref } from './workspaceMenuHref'
import { isOperatorInWorkspace } from './operatorRoutes'
@@ -48,14 +48,16 @@
item: MenubarMenuElements['item']
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
strictWorkspaceSelect?: boolean
/** Closes the menu this list lives in, so a workspace switch does not run under an open
* panel. The host owns the menu, so only it can. */
closeMenu?: () => void
// Set by a picker embedded in a page that drives its own navigation, so switching
// workspaces must not navigate away from it. Separate from strictWorkspaceSelect,
// which only strips the sidebar-specific rows — the operator submenu sets that one
// and still wants the switch to move the page.
// which only strips the sidebar-specific rows.
keepPageOnSwitch?: boolean
}
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false }: Props = $props()
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false, closeMenu }: Props = $props()
async function toggleSwitchWorkspace(id: string) {
if ($workspaceStore === id) {
@@ -107,8 +109,13 @@
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
return
}
// This click is handled here rather than by melt, so melt never closes the menu for it and
// the panel would sit through the switch — which renames the workspace in the header,
// moving the very trigger the panel hangs from, with floating-ui chasing it across the
// viewport. Close first, let that reach the DOM, then switch.
e.preventDefault()
toggleSwitchWorkspace(workspace.id)
closeMenu?.()
void tick().then(() => toggleSwitchWorkspace(workspace.id))
}
// Family-first picker: list the workspace families (roots) with their forks
@@ -117,7 +124,7 @@
// scope header's WorkspaceFamilyPicker remains the primary fork surface.
//
// strictWorkspaceSelect is used where this list has no scope header beside it
// (standalone pages such as svix webhook creation, the operator menu), so
// (standalone pages such as svix webhook creation), so
// there forks must stay directly selectable — list the full hierarchy
// unconditionally.
const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : [])
@@ -140,16 +147,41 @@
const canForkHere = $derived(
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
)
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
// stops at that same visibility boundary, so it lands on the same row.
const lineageRoot = $derived.by(() => {
const id = $workspaceStore ?? undefined
if (!id) return undefined
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
})
const familyWorkspaces = $derived.by(() => {
if (strictWorkspaceSelect) return hierarchy
let rootId: string | undefined
return hierarchy.filter((h) => {
if (h.depth === 0) {
rootId = h.workspace.id
return true
}
return !!rootId && expandedFamilies.has(rootId)
})
const visible = strictWorkspaceSelect
? hierarchy
: hierarchy.filter((h) => {
if (h.depth === 0) {
rootId = h.workspace.id
return true
}
return !!rootId && expandedFamilies.has(rootId)
})
// The family the user is standing in leads the list, its forks travelling with it: the
// workspace they are in is the one they look for first, and the one the tick is on.
const activeRoot = lineageRoot?.id
if (!activeRoot) return visible
const families: { root: string | undefined; rows: typeof visible }[] = []
for (const h of visible) {
if (h.depth === 0 || families.length === 0) families.push({ root: h.workspace.id, rows: [h] })
else families[families.length - 1].rows.push(h)
}
const at = families.findIndex((f) => f.root === activeRoot)
if (at <= 0) return visible
const [active] = families.splice(at, 1)
return [active, ...families].flatMap((f) => f.rows)
})
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
@@ -173,27 +205,13 @@
e.stopPropagation()
}
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
// stops at that same visibility boundary, so it lands on the same row.
const lineageRoot = $derived.by(() => {
const id = $workspaceStore ?? undefined
if (!id) return undefined
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
})
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
// Seeded once per instance: opening while a fork is active expands that fork's family,
// putting the tick on the active fork's own row instead of on its collapsed root. The
// host menu renders this only while open, so in practice each open gets a fresh
// instance and therefore a fresh expansion state — except across a close and re-open
// inside the menu's 100ms outro, where Svelte resumes the same instance and whatever
// was expanded by hand survives.
// Seeded once per instance: the family the user is standing in opens expanded, so its forks —
// the ones they are most likely to switch between — are there without a click, and the tick
// sits on the actual workspace rather than on its collapsed root. Other families stay closed.
untrack(() => {
if (lineageRoot && lineageRoot.id !== $workspaceStore) {
if (lineageRoot && familiesWithForks.has(lineageRoot.id)) {
expandedFamilies.add(lineageRoot.id)
}
})
@@ -345,11 +363,7 @@
</MenuItem>
{/if}
{#if canForkHere}
<MenuItem
class={itemClass}
onClick={() => (globalForkModal.val = { opened: true })}
{item}
>
<MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}>
<Plus size={16} />
Workspace fork
</MenuItem>
@@ -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,20 @@
import { fromStore } from 'svelte/store'
import { userStore } from '$lib/stores'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
// Empty until the user presses detach or attach once. Until then operators start detached:
// many of them navigate through a workspace's own app, and a docked sidebar would take its width.
const pref = useLocalStorageValue<string>('nav_detached', '', 'string')
const user = fromStore(userStore)
/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */
export const navDetached = {
get val(): boolean {
if (pref.val === 'true') return true
if (pref.val === 'false') return false
return !!user.current?.operator
},
set val(detached: boolean) {
pref.val = String(detached)
}
}
@@ -0,0 +1,48 @@
// Opening the detached sidebar's card, from the handle or from anywhere else that reaches for the
// nav (the left edge band, the AI chat layout's menu button).
let opener: (() => void) | undefined
// Closing it once the pointer is off both the handle and the card, which the layout owns because
// the card is its markup.
let hoverClose: { schedule: () => void; cancel: () => void } | undefined
// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than
// the card starting at whatever height the control that opened it happens to have. Measured at open
// time rather than fixed, so the card follows the header's own height. On a page that hides the
// header, the header is still what the card will sit under — it comes down with it — so its height
// is read even while it is off-screen. The fallback matches the header's resting height.
const FALLBACK_CARD_TOP = 44
const GAP_UNDER_HANDLE = 6
let cardTop = $state(FALLBACK_CARD_TOP)
export const navHandleSlot = {
/** Distance from the top of the viewport to the card's top edge, in px. */
get cardTop(): number {
return cardTop
},
/** The root layout owns the card, so it registers how to open and hover-close it. */
setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) {
opener = open
hoverClose = close
},
/** Pointer left the handle: close, unless it lands on the card before the delay is up. */
scheduleClose() {
hoverClose?.schedule()
},
/**
* Opens the card under the page header, or under `anchor` when there is no header to sit
* beneath — an embed, where whatever reached for the nav is all the card has to hang from.
*/
open(anchor?: Element | null) {
hoverClose?.cancel()
const band = document.querySelector('[data-page-header]')
const bandHeight = band?.getBoundingClientRect().height
if (bandHeight) {
cardTop = Math.round(bandHeight)
} else {
const bottom = anchor?.getBoundingClientRect().bottom
cardTop =
bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP
}
opener?.()
}
}
@@ -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
}
}
@@ -1,61 +1,22 @@
import { base } from '$lib/base'
import type { OperatorSettings } from '$lib/gen'
import type { UserWorkspace } from '$lib/stores'
/** An `operator_settings` key: each admits an operator to one page (`triggers` to all of them). */
export type OperatorPageKey = keyof NonNullable<OperatorSettings>
/**
* The pages an operator can reach, each paired with the `operator_settings` key
* that admits them — the operator menu lists only the entries the active
* workspace permits.
* Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page
* (other gates, such as admin-only entries, still apply); an operator sees only the pages the
* workspace's `operator_settings` turn on.
*/
export type OperatorMenuLink = {
label: string
/** `operator_settings` key gating the page. */
id: string
href: string
export function sidebarPageAllowed(
isOperator: boolean | undefined,
workspace: UserWorkspace | undefined,
key: OperatorPageKey
): boolean {
return !isOperator || workspace?.operator_settings?.[key] === true
}
export type OperatorTriggerLink = OperatorMenuLink & { kind: string }
/** Home needs no setting — it is the one page every operator always has. */
export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [
{ label: 'Home', id: 'home', href: `${base}/` },
{ label: 'Runs', id: 'runs', href: `${base}/runs` },
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules` }
]
export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [
{ label: 'Resources', id: 'resources', href: `${base}/resources` },
{ label: 'Variables', id: 'variables', href: `${base}/variables` },
{ label: 'Assets', id: 'assets', href: `${base}/assets` },
{ label: 'Groups', id: 'groups', href: `${base}/groups` },
{ label: 'Folders', id: 'folders', href: `${base}/folders` },
{ label: 'Workers', id: 'workers', href: `${base}/workers` },
{ label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` }
]
export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [
{ label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' },
{ label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' },
{
label: 'Postgres triggers',
id: 'triggers',
href: `${base}/postgres_triggers`,
kind: 'postgres'
},
{ label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' },
{ label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' },
{ label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' },
{ label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' },
{
label: 'Azure Event Grid triggers',
id: 'triggers',
href: `${base}/azure_triggers`,
kind: 'azure'
},
{ label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' },
{ label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' },
{ label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' }
]
/**
* Whether the user is an operator in `workspace`. The server nulls
* `operator_settings` for a non-operator, so a non-null value narrows an operator
@@ -1,8 +1,7 @@
<script lang="ts">
import type { DriveStep } from 'driver.js'
import { wait } from '$lib/utils'
import Tutorial from './Tutorial.svelte'
import { markOperatorTourSeen, MENU_OPEN_DELAY_MS } from './operatorTour'
import { markOperatorTourSeen } from './operatorTour'
let tutorial: Tutorial | undefined = $state(undefined)
let running = false
@@ -25,7 +24,7 @@
<Tutorial
bind:this={tutorial}
{onDestroyed}
getSteps={(driver) => {
getSteps={() => {
const steps: DriveStep[] = [
{
popover: {
@@ -60,19 +59,12 @@
},
{
popover: {
title: 'Finally, the Menu section',
title: 'Finally, the sidebar',
description:
'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from that same menu.</p>',
onNextClick: async () => {
// The step points into the menu, so it has to be open before the popover
// lands on it — and open is also where the entry to re-run the tour is.
const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
menuButton?.click()
await wait(MENU_OPEN_DELAY_MS)
driver.destroy()
}
'Explore the pages you can open from here: your history of runs, your scheduled scripts, AI sessions and your workspaces.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from your account menu, under <strong>Settings</strong>.</p>'
},
element: '[role="menuitem"]'
// Whichever the sidebar currently is: the floating handle when detached, the rail when not.
element: '[data-nav-handle], #sidebar'
}
]
@@ -13,8 +13,6 @@ export const TOUR_PARAM_VALUE = 'operator'
/** Long enough for the home page's tabs to exist before the first step points at one. */
export const TOUR_START_DELAY_MS = 500
/** Time for the sidebar to open before the last step points into it. */
export const MENU_OPEN_DELAY_MS = 300
export async function hasSeenOperatorTour(): Promise<boolean> {
// A failure answers "seen": the tour interrupts the page, and interrupting someone who
File diff suppressed because it is too large Load Diff
@@ -19,11 +19,13 @@
import { writable } from 'svelte/store'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { setQuery } from '$lib/navigation'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
@@ -92,6 +94,22 @@
let showCreateButtons = $state(false)
// A workspace switch keeps this page mounted and swaps its contents under the reader, which
// the mount animation below never covers. Replay it by hand instead of keying the page on the
// workspace: a remount would drop the list's own state and refetch what it is already loading.
let pageEl: HTMLDivElement | undefined = $state(undefined)
const reducedMotion = useReducedMotion()
let animatedWorkspace = $workspaceStore
$effect(() => {
const ws = $workspaceStore
if (ws === animatedWorkspace) return
animatedWorkspace = ws
untrack(() => {
if (!pageEl || reducedMotion.val) return
pageEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' })
})
})
let operatorTour: OperatorTour | undefined = $state(undefined)
// Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
@@ -262,16 +280,25 @@
</DrawerContent>
</Drawer>
{#snippet editRuleBadge()}
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{/snippet}
<div
bind:this={pageEl}
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
style="scrollbar-gutter: stable both-edges;"
>
<!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the
header with the workspace's own name rather than above the list. -->
<PageHeaderContent actions={editRuleBadge} />
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
so it shows whenever the sessions beta is on; the composer itself is gated on operator
status and on the workspace inside the component, which owns its own vertical spacing
so it shows whenever the sessions beta is on; the composer itself is gated on the
workspace inside the component, which owns its own vertical spacing
because the hero and the bare connect row want different amounts of it. -->
{#if isGlobalAiEnabled()}
<HomeAIChat />
@@ -285,8 +312,6 @@
<div class="my-4"></div>
{/if}
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if tab == 'hub'}
<div class="flex flex-col gap-y-16">
<div class="flex flex-col pb-8">
@@ -6,6 +6,7 @@
* here — links to an app point at this route whatever its kind; the sibling
* /apps_raw/get route wraps the same component.
*/
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
import { Skeleton } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
@@ -18,6 +19,10 @@
<!-- Wait for the active workspace before mounting the embedder: it's needed to
mint the token and to build the viewer iframe URL, and the store is set
asynchronously by the (logged) layout. -->
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
<PageHeaderContent fullBleed />
{#if workspace && path}
<!-- Key by target: SvelteKit reuses this page component on in-route
navigation (e.g. a navbar item linking to another app), so the viewer
@@ -545,7 +545,7 @@
setTimeout(async () => {
// Falls through when the hand-off has no path to open, so the click
// still reaches the legacy path (or its toast) instead of vanishing.
if (prefersSessionHandoff($userStore?.operator)) {
if (prefersSessionHandoff()) {
if (await rawAppEditor?.openInSession(prompt)) return
}
aiChatManager.changeMode(AIMode.APP)
@@ -5,6 +5,7 @@
* apps get the identical sandbox behavior. PublicAppFrame renders raw apps
* inline with the bundle isolated in RawAppPreview's own opaque iframe.
*/
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
import { Skeleton } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
@@ -14,6 +15,10 @@
let path = $derived(page.params.path ?? '')
</script>
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
<PageHeaderContent fullBleed />
{#if workspace && path}
<!-- Key by target: SvelteKit reuses this page component on in-route
navigation, so the viewer must fully remount (see /apps/get). -->
@@ -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'
@@ -40,7 +41,7 @@
import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte'
import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte'
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import {
getOrCreateRuntime,
getRuntime,
@@ -196,7 +197,7 @@
// load. `recovering` also guards re-entry: recovery mutates the session list
// this effect tracks, while the URL that would stop it only updates on `goto`.
$effect(() => {
if (embedded || !globalEnabled || $userStore?.operator) return
if (embedded || !globalEnabled) return
if (!sessionState.hydrated || recovering) return
// A deliberate delete removes the open session ahead of its own navigation.
// Claiming that gap would take over the URL and tell the user the session
@@ -434,6 +435,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.
@@ -867,6 +870,29 @@
}}
/>
<!-- 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 || previewWidth === 0 ? undefined : previewWidth}
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
@@ -884,24 +910,6 @@
}}>Open sessions</Button
>
</div>
{:else if $userStore?.operator}
<!-- Operators are exempt from the sessions beta (the layout keeps their
legacy docked chat); a direct URL must not bypass that. -->
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
<p class="text-primary font-medium">AI Sessions are not available for operators</p>
<p>Use the Ask AI chat instead.</p>
<Button
size="xs"
onclick={() => {
try {
localStorage.setItem('ai-chat-open', 'true')
} catch {}
window.location.href = `${base}/`
}}
>
Open Ask AI chat
</Button>
</div>
{:else if !globalEnabled}
<!-- Direct navigation (bookmark, shared link) while the user has opted out
of the beta: offer the way back in instead of a dead end. -->
@@ -945,7 +953,14 @@
{#if !fullscreen}
<!-- Chat column. Recently visited sessions stay mounted (stacked,
visibility-toggled) — see mountChat. -->
<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-11'}"
>
<div class="relative flex-1 min-h-0">
{#each mountedChatSessions as s (s.id)}
<div
@@ -954,7 +969,7 @@
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} />
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
</div>
{/each}
</div>
@@ -970,7 +985,10 @@
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
? ''
@@ -1199,21 +1217,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">