feat: the band owns its bottom edge, and one pen renames everywhere

The page header decides whether a separator is drawn, so a page cannot draw a
second line under it. A registration asks for `onScroll` (the default, drawn
only while something passes under the bar), `always` for a page whose content
starts right beneath it, or `none` where the page genuinely draws its own first
line. Detail pages, the agent editor, workspace settings and the pipeline list
take `always`; the raw app editor takes `none`, since its splitpanes already
draw that line.

The pen that opens the rename popover becomes PathEditPen, shared by the
editors and the deployed pages so the two cannot drift, and the deployed pages
gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole
name block, so the name is selectable text again. Renaming an agent goes through
ResourceService with only the fields the popover edits: the backend sets a
column per field it is given, so writing the config back would clobber a
concurrent edit. The popover also stops clearing a real collision verdict by
telling Path which path is its own.

Two Button fixes, both reaching call sites beyond this work:

- `dropdownItems` given as a function was asked for `.length`, which is its
  arity and therefore 0, so a split button built that way rendered no caret.
  Both app editors' Deploy dropdowns were unreachable.
- the anchor branch dropped `title`, so a link button had no tooltip.

The audit log filters move into the header actions at the design system's sizes
and trade absolutely positioned labels for the form layout in
brand-guidelines.md: a label above its input, 4px apart, fields 8px apart.

The deployed app viewer floors itself at `min-h-full` instead of claiming the
viewport, which would overhang the page box by the height of the band, and
carries Edit and a public-url link that fold into a menu on a narrow bar. The
root layout drops the legacy menu block, which rendered a second hidden sidebar
and 54 invisible tab stops, and marks the closed detached sidebar `inert`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-05 01:42:59 -07:00
co-authored by Claude Opus 5
parent faa608dc69
commit 666a2feb52
28 changed files with 540 additions and 381 deletions
@@ -1570,6 +1570,8 @@
{#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. -->
<!-- `separator="none"`: the canvas right below the bar draws its own top line, and the
bar's edge would be a second one on top of it. -->
<PageHeaderContent
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
@@ -1579,6 +1581,7 @@
}}
actions={flowHeaderActions}
contexts={headerContexts}
separator="none"
/>
{:else}
<!-- Nav between steps-->
@@ -70,21 +70,24 @@ The row's height matches the sidebar's own header row, so the two read as one ba
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
</script>
<svelte:window onscrollcapture={onScrollCapture} />
<!-- The band draws its own bottom edge, and only while a page is scrolled under it: at the top of
a page there is nothing to divide, and pages that draw their own first line have dropped it —
two would stack. An inset shadow rather than a border: a border would make the row 45px and
every page below it would move a pixel the moment the edge appeared. -->
<!-- The band owns its bottom edge, so a page never draws a second one under it. A page says which
rule it wants (see `separator` in the registry); the default shows the edge only while
something is passing under the bar. An inset shadow rather than a border: a border would make
the row 45px and every page below it would move a pixel the moment the edge appeared. -->
<div
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
phone ? 'flex-wrap content-center min-h-11 py-1 pl-1 pr-2' : 'h-11 pl-2 pr-4',
scrolledUnder &&
showEdge &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
@@ -17,6 +17,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actions,
actionsOrder,
actionsFlexible,
separator,
contexts,
actingWorkspaceId,
barRightInset
@@ -30,6 +31,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actions,
actionsOrder,
actionsFlexible,
separator,
contexts,
actingWorkspaceId,
barRightInset
@@ -0,0 +1,37 @@
<!--
@component
The pen that opens an item's summary and path for editing, beside the breadcrumb that shows them.
One affordance for both popovers behind it: an editor's, which edits the draft the next deploy
writes, and a deployed page's, which saves straight away. They look the same because they mean
the same thing to a reader — "rename this".
-->
<script lang="ts">
import { Pencil } from 'lucide-svelte'
import { Button } from '$lib/components/common'
interface Props {
/** Names what the popover offers, for the tooltip and the accessible name. */
label: string
/** `hover` keeps the pen out of the way until the row is hovered, which is what the band
* wants beside a name; `always` is for a name with nothing else to point at, such as an
* item with no summary yet. */
visibility?: 'hover' | 'always'
/** Held visible while the popover it opens is open, whatever `visibility` says. */
open?: boolean
}
let { label, visibility = 'hover', open = false }: Props = $props()
</script>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Pencil }}
title={label}
aria-label={label}
btnClasses={visibility === 'hover' && !open
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
: ''}
/>
@@ -8,8 +8,8 @@ to it — does not reflow under the pointer as the user types, which floating-ui
-->
<script lang="ts">
import type { ComponentProps } from 'svelte'
import { Pencil } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import PathEditPen from '$lib/components/PathEditPen.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Path from '$lib/components/Path.svelte'
import Label from '$lib/components/Label.svelte'
@@ -100,17 +100,7 @@ to it — does not reflow under the pointer as the user types, which floating-ui
bind:isOpen={() => open, setOpen}
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: Pencil }}
title={penLabel}
aria-label={penLabel}
btnClasses={penVisibility === 'hover' && !open
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
: ''}
/>
<PathEditPen label={penLabel} visibility={penVisibility} {open} />
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-6 w-[480px]">
@@ -136,15 +126,19 @@ to it — does not reflow under the pointer as the user types, which floating-ui
</Label>
{/if}
{#if own}
<!-- allowedExistingPath: the item already occupies its saved path, so typing it back
after a rename that has not been deployed is not a collision. `initialPath`
cannot say so — it is the working path, which moves with the rename. -->
<!-- Both of these say the same thing — the item's own saved path is not a collision —
and both are needed, because `Path` skips the whole existence check while the
typed path equals `initialPath`. Seeding that from the working path instead
would make a rename to a taken path look untaken the moment the popover is
reopened on it. With no saved path there is nothing the item occupies, so every
path is checked. -->
<Path
autofocus
bind:path
bind:error
initialPath={snapshotPath ?? path ?? ''}
initialPath={savedPath ?? path ?? ''}
allowedExistingPath={savedPath}
checkInitialPathExistence={savedPath == undefined}
namePlaceholder={kind}
{kind}
size="sm"
@@ -2281,6 +2281,8 @@
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the script's path and
summary are the breadcrumb's, and its buttons are the header's actions. -->
<!-- `separator="none"`: the toolbar right below the bar draws its own `border-b`, and the
bar's edge would be a second line on top of it. -->
<PageHeaderContent
item={{
// Frozen while the pen's popover is open so the trail holds still as the user types.
@@ -2290,6 +2292,7 @@
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
separator="none"
/>
{:else}
<div
@@ -10,6 +10,7 @@
import Label from './Label.svelte'
import LabelsInput from './LabelsInput.svelte'
import InheritedLabels from './InheritedLabels.svelte'
import PathEditPen from './PathEditPen.svelte'
import Badge from './common/badge/Badge.svelte'
import {
useOperatingUser,
@@ -27,7 +28,9 @@
inheritedLabels?: string[] | undefined
editable?: boolean
onSaved?: (newPath: string) => void
kind?: 'flow' | 'script'
/** What is being renamed. An agent is a resource, so the path field is scoped to one and
* the write goes through the agent branch of the rename manager. */
kind?: 'flow' | 'script' | 'agent'
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
}
@@ -43,6 +46,10 @@
compact = false
}: Props = $props()
// An agent lives at a resource path, so that is what the picker validates against; the
// placeholder still says "agent", which is what the reader is naming.
const pathKind = $derived(kind === 'agent' ? 'resource' : kind)
let editSummary = $state('')
let editPath = $state('')
let dirtyPath = $state(false)
@@ -85,7 +92,7 @@
newSummary: editSummary,
labels
})
sendUserToast(`${kind === 'flow' ? 'Flow' : 'Script'} updated`)
sendUserToast(`${kind.charAt(0).toUpperCase()}${kind.slice(1)} updated`)
labelsDirty = false
close()
onSaved?.(newPath)
@@ -96,151 +103,162 @@
</script>
{#if editable || onSaved}
<Popover
class="min-w-0 max-w-full"
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={() => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
<!-- The name reads as text and the pen beside it opens the popover, the same way the editors
carry theirs. The whole block used to be the trigger, so a name you only wanted to read
answered a click — and the pen has to sit outside the popover to be the only thing that
does. -->
<div
class="min-w-0 truncate flex {compact
? 'items-center px-1 py-0.5'
: 'flex-col items-start px-2 py-1'}"
>
{#snippet trigger()}
<div
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
? 'items-center px-1 py-0.5'
: 'flex-col items-start px-2 py-1'}"
{#if !compact}
<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="{compact
? 'text-xs font-medium'
: 'text-sm font-semibold'} truncate {emptyString(summary)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels as label}
<Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge>
{/each}
</div>
{/if}
<InheritedLabels labels={inheritedLabels} />
{/if}
<div class="group flex items-center gap-3 max-w-full min-w-0">
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
summary
)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels as label}
<Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge>
{/each}
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-6 w-[480px]">
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
save(close)
}
}
}}
bind:value={editSummary}
/>
</Label>
<div class="-mt-4 flex items-center gap-2">
<LabelsInput
bind:labels
onchange={() => {
labelsDirty = true
}}
/>
{#if inheritedLabels?.length}
<InheritedLabels labels={inheritedLabels} />
{/if}
</div>
{#if inheritedLabels?.length}
<p class="-mt-5 text-2xs text-tertiary">
Gray labels are inherited from the folder and can only be edited there.
</p>
{/if}
<Label label="Path">
{#if own}
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
/>
{/if}
<InheritedLabels labels={inheritedLabels} />
<Popover
class="shrink-0"
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={() => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<!-- Both of the popover's arms show the summary and the path, and ownership — which
is only resolved once it opens — decides whether the path can be changed, not
whether it is there. -->
<PathEditPen
label="Edit summary and path"
visibility={emptyString(summary) ? 'always' : 'hover'}
open={popoverOpen}
/>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-6 w-[480px]">
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
save(close)
}
}
}}
bind:value={editSummary}
/>
</Label>
<div class="-mt-4 flex items-center gap-2">
<LabelsInput
bind:labels
onchange={() => {
labelsDirty = true
}}
/>
{#if inheritedLabels?.length}
<InheritedLabels labels={inheritedLabels} />
{/if}
</div>
{#if inheritedLabels?.length}
<p class="-mt-5 text-2xs text-tertiary">
Gray labels are inherited from the folder and can only be edited there.
</p>
{/if}
<Label label="Path">
{#if own}
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
{:else}
<span class="text-xs font-mono text-secondary">{path}</span>
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
{/if}
</Label>
{#if onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" size="xs">
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => save(close)}
>
Save
</Button>
{:else}
<span class="text-xs font-mono text-secondary">{path}</span>
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
close()
}
}
}}
bind:value={summary}
/>
</label>
<div class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
kind={pathKind}
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</Label>
{#if onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" size="xs">
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => save(close)}
>
Save
</Button>
{:else}
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
close()
}
}
}}
bind:value={summary}
/>
</label>
<div class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</div>
{/snippet}
</Popover>
{/snippet}
</Popover>
</div>
</div>
{:else}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
@@ -634,6 +634,22 @@
icon: $breakpoint === 'sm' ? Laptop2 : Smartphone,
action: () => ($breakpoint = $breakpoint === 'sm' ? 'lg' : 'sm')
},
// The setting the mobile view is edited for, which otherwise lives beside the
// toggle group that stood down: without it here a deployed app could be put on
// the mobile layout at this width and never taken off it.
...($breakpoint === 'sm'
? [
{
displayName: $app?.mobileViewOnSmallerScreens
? 'Mobile view on small screens: on'
: 'Mobile view on small screens: off',
icon: Smartphone,
action: () => {
if ($app) $app.mobileViewOnSmallerScreens = !$app.mobileViewOnSmallerScreens
}
}
]
: []),
{
displayName: $app?.fullscreen ? 'Centered canvas' : 'Full-width canvas',
icon: $app?.fullscreen ? AlignHorizontalSpaceAround : Expand,
@@ -989,6 +1005,7 @@
}}
actions={appHeaderActions}
contexts={headerContexts}
separator="always"
/>
{:else}
<div
@@ -14,12 +14,15 @@
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 DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import type { Item } from '$lib/utils'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import type { UserExt } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Pen } from 'lucide-svelte'
import { ExternalLink, Pen } from 'lucide-svelte'
import { page } from '$app/state'
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
import {
@@ -172,9 +175,53 @@
}
}
/** The app's public url, undefined until it is known and for anything that has none — an app
* that was never deployed has no row for `secret_of` to answer about. Only fetched where the
* button that opens it is shown. */
let publicUrl = $state<string | undefined>(undefined)
async function loadPublicUrl() {
try {
const secret = await AppService.getPublicSecretOfApp({ workspace, path })
// Built from this viewer's workspace rather than the navigation one, like everything
// else here: the two differ inside a session's preview panel.
publicUrl = secret
? `${window.location.origin}${base}/public/${workspace}/${secret}`
: undefined
} catch (_) {
publicUrl = undefined
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
$effect(() => {
publicUrl = undefined
if (ownsPageHeader && !menuHidden && workspace && path) loadPublicUrl()
})
// Both of this page's buttons carry a label, and a phone's bar has room for neither beside the
// app's path — so below that width they become one menu. Unmeasured (0) counts as wide: the bar
// measures itself on mount, and starting compact would pop them out a frame later.
const compact = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
const compactItems: Item[] = $derived([
...(publicUrl
? [
{
displayName: 'Public url',
icon: ExternalLink,
href: publicUrl,
hrefTarget: '_blank' as const
}
]
: []),
{
displayName: 'Edit',
icon: Pen,
href: onEdit ? undefined : editHref,
action: onEdit ? () => onEdit() : undefined
}
])
</script>
<div class="h-full">
@@ -208,14 +255,15 @@
{#if ownsPageHeader}
<PageHeaderContent
item={{ kind: 'app', path }}
afterName={showEdit && !menuHidden ? editAction : undefined}
actions={showEdit && !menuHidden ? editAction : undefined}
separator="always"
/>
{/if}
<!-- Edit sits with the app's name when this viewer owns the band, and floats over the canvas
otherwise: an embed has no band — it would cost the app 44px of the iframe to carry one
button — and inside a session's preview panel the band belongs to the session, so an Edit
up there would sit beside the session's name and act on the panel below it. -->
<!-- Edit is the bar's action when this viewer owns the bar, and floats over the canvas otherwise:
an embed has no bar — it would cost the app 44px of the iframe to carry one button — and
inside a session's preview panel the bar belongs to the session, so an Edit up there would
sit beside the session's name and act on the panel below it. -->
{#if showEdit && (menuHidden || !ownsPageHeader)}
<div class="absolute bottom-4 right-4 z-50">
{@render editAction()}
@@ -223,14 +271,34 @@
{/if}
{#snippet editAction()}
<!-- `onEdit` wins over the href: a host that embeds this viewer opens its own editor rather
than navigating the frame to one. -->
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href={onEdit ? undefined : editHref}
on:click={() => onEdit?.()}
id="app-edit-btn">Edit</Button
>
{#if compact}
<!-- Too little bar to seat both: they fold into one menu rather than losing their labels,
which are what say where each one goes. -->
<DropdownV2 items={compactItems} placement="bottom-end" size="sm" />
{:else}
{#if publicUrl}
<!-- The app on its own, at the url anyone it is shared with uses. A new tab rather than
this one: the viewer here is the same app, so replacing it would look like nothing
happened. -->
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: ExternalLink }}
href={publicUrl}
target="_blank"
title="Open the app's public url in a new tab">Public url</Button
>
{/if}
<!-- `onEdit` wins over the href: a host that embeds this viewer opens its own editor rather
than navigating the frame to one. -->
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="default"
href={onEdit ? undefined : editHref}
on:click={() => onEdit?.()}
title="Edit this app"
id="app-edit-btn">Edit</Button
>
{/if}
{/snippet}
@@ -187,10 +187,12 @@
<div
class={twMerge(
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
// the public viewer always used a plain `flex` wrapper. The height comes from
// the host, like the raw branch above: a viewport floor here would overhang the
// page box by the height of the page header band.
inWorkspace ? 'h-full w-full flex flex-col' : 'h-full w-full flex',
// the public viewer always used a plain `flex` wrapper. `min-h-full`, not
// `h-full`: the box has to reach the bottom of what the host gives it so the
// app's own background covers the window, and still grow with a grid taller
// than that. A viewport floor instead would overhang the page box by the
// height of the page header band.
inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
@@ -14,7 +14,7 @@
contentClasses="p-4 flex flex-col w-80 pt-8 bg-surface"
>
{#snippet trigger()}
<Button variant="accent" unifiedSize="md" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
<Button variant="accent" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
Filters
</Button>
{/snippet}
@@ -266,12 +266,20 @@
;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex]
untrack(() => updateQueryParams())
})
// `leading-none`: the label's own line box is what decides whether a stacked field fits the
// header band. At the default leading the field is 52px against a 50px bar and the label is
// clipped off the top; at the font's own height it is 44px.
const fieldLabel = 'text-xs font-semibold text-emphasis shrink-0 leading-none'
</script>
<div class="flex flex-col gap-8 2xl:gap-2 2xl:flex-row mt-4 xl:mt-0 pr-2">
<!-- Label above input, 4px apart, fields 8px apart — the form layout in brand-guidelines.md. The
labels used to be positioned absolutely above the controls, which put them outside the box and
clipped them against the top of the window once these filters moved into the header band. -->
<div class="flex flex-col gap-2 2xl:flex-row 2xl:items-end pr-2">
{#if $workspaceStore == 'admins'}
<div class="flex gap-1 relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Scope</span>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Scope</span>
<ToggleButtonGroup
selected={scope ?? 'admins'}
on:selected={({ detail }) => {
@@ -301,43 +309,48 @@
</ToggleButtonGroup>
</div>
{/if}
<div class="flex relative bg-surface-input">
<span class="text-xs absolute font-semibold text-emphasis -top-4">From</span>
<input type="text" value={after ?? 'From'} disabled />
<CalendarPicker
clearable
date={after}
placement="bottom-end"
label="From"
on:change={({ detail }) => {
after = new Date(detail).toISOString()
}}
on:clear={() => {
after = undefined
}}
/>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>From</span>
<div class="flex items-center relative min-w-0 bg-surface-input">
<input type="text" class="!h-7 !text-xs" value={after ?? ''} disabled />
<CalendarPicker
clearable
date={after}
placement="bottom-end"
label="From"
on:change={({ detail }) => {
after = new Date(detail).toISOString()
}}
on:clear={() => {
after = undefined
}}
/>
</div>
</div>
<div class="flex relative bg-surface-input">
<span class="text-xs absolute font-semibold text-emphasis -top-4">To</span>
<input type="text" value={before ?? 'To'} disabled />
<CalendarPicker
clearable
bind:date={before}
label="To"
placement="bottom-end"
on:change={({ detail }) => {
before = new Date(detail).toISOString()
}}
on:clear={() => {
before = undefined
}}
/>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>To</span>
<div class="flex items-center relative min-w-0 bg-surface-input">
<input type="text" class="!h-7 !text-xs" value={before ?? ''} disabled />
<CalendarPicker
clearable
bind:date={before}
label="To"
placement="bottom-end"
on:change={({ detail }) => {
before = new Date(detail).toISOString()
}}
on:clear={() => {
before = undefined
}}
/>
</div>
</div>
<div class="flex relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Username</span>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Username</span>
<Select
class="w-full"
size="sm"
bind:value={username}
RightIcon={ChevronDown}
items={usernames
@@ -354,11 +367,11 @@
: []}
/>
</div>
<div class="flex relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Resource</span>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Resource</span>
<Select
class="w-full"
size="sm"
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
createText="Press enter to use this value"
bind:value={resource}
@@ -368,11 +381,11 @@
/>
</div>
<div class="flex relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Operation</span>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Operation</span>
<Select
class="w-full"
size="sm"
bind:value={operation}
items={['all', ...Object.values(operations)].map((r) => ({ value: r, label: r }))}
inputClass="dark:!bg-gray-700"
@@ -380,11 +393,11 @@
/>
</div>
<div class="flex relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Action</span>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Action</span>
<Select
class="w-full"
size="sm"
bind:value={actionKind}
RightIcon={ChevronDown}
items={[
@@ -397,7 +410,9 @@
/>
</div>
<div class="flex flex-row">
<!-- The buttons carry no label of their own, so they line up with the inputs beside them rather
than with the labels above those. -->
<div class="flex flex-row items-center gap-1">
<Button
variant="subtle"
on:click={() => {
@@ -411,14 +426,14 @@
resource = 'all'
scope = undefined
}}
unifiedSize="md"
unifiedSize="sm"
>
Clear filters
</Button>
<Button
variant="subtle"
on:click={downloadAuditLogsAsJson}
unifiedSize="md"
unifiedSize="sm"
title="Downloads currently displayed logs only (up to {perPage} entries)"
startIcon={{ icon: Download }}
iconOnly
@@ -430,7 +445,7 @@
resources.refresh()
onRefresh?.()
}}
unifiedSize="md"
unifiedSize="sm"
wrapperClasses="ml-auto"
>
<div class="flex flex-row gap-1 items-center">
@@ -148,6 +148,13 @@
}: Props = $props()
const activeTone = $derived(dropdownItems ? undefined : tone)
// `dropdownItems` may be a function, whose `.length` is its arity — 0 for the `() => [...]`
// every caller writes. Asking it for its items is the only way to know whether there are any,
// and a split button that answers `.length` instead renders no caret at all.
const hasDropdown = $derived(
!!dropdownItems &&
(typeof dropdownItems === 'function' ? dropdownItems().length : dropdownItems.length) > 0
)
function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] {
const items = typeof menuItems === 'function' ? menuItems() : menuItems
@@ -197,12 +204,7 @@
]
: ButtonType.VariantStyles[variant]
// For default variant with dropdowns, remove border from button since it's on wrapper
if (
variant === 'default' &&
dropdownItems &&
((typeof dropdownItems === 'function' && dropdownItems().length > 0) ||
dropdownItems.length > 0)
) {
if (variant === 'default' && hasDropdown) {
style = style.replace('border border-border-light', '')
}
return style
@@ -278,7 +280,7 @@
getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize),
unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '',
'focus-visible:ring-2',
dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md',
hasDropdown ? 'rounded-l-md' : 'rounded-md',
'justify-center items-center text-center inline-flex gap-2',
'active:opacity-80 transition-[background-color,opacity] duration-150',
disabled
@@ -345,7 +347,7 @@
<div
class={twMerge(
dropdownItems && dropdownItems.length > 0 ? dividerClass : '',
hasDropdown ? dividerClass : '',
'shrink-0',
wrapperClasses,
'flex flex-row rounded-md',
@@ -385,6 +387,7 @@
class={buttonClass}
{id}
{target}
{title}
tabindex={disabled ? -1 : 0}
{...rest}
{style}
@@ -484,7 +487,7 @@
{/if}
{/if}
{#if dropdownItems && dropdownItems.length > 0}
{#if hasDropdown && dropdownItems}
<Dropdown
aiId={aiId ? `${aiId}-dropdown` : undefined}
aiDescription={aiDescription ? `${aiDescription} dropdown` : undefined}
@@ -50,6 +50,9 @@
tag?: string | undefined
/** Unset for what has no workspace error handler to mute, such as an agent. */
errorHandlerKind?: 'flow' | 'script'
/** What the rename popover is renaming, when that is not the error handler's kind — an
* agent has no error handler but is still renamed from here. */
itemKind?: 'flow' | 'script' | 'agent'
scriptOrFlowPath?: string
errorHandlerMuted?: boolean | undefined
labels?: string[] | undefined
@@ -76,6 +79,7 @@
path,
tag,
errorHandlerKind,
itemKind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
labels = $bindable(),
@@ -90,13 +94,19 @@
const dispatch = createEventDispatcher()
// A flow and a script are renamed as what their error handler already names them; an agent has
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
// own box is narrow while the window is not — a session's preview panel — and says so through
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
// with the breadcrumb, and the trail and summary want ~560px against the set's ~530. Unmeasured
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
// the buttons out of the menu a frame later.
const COLLAPSE_BELOW = 1150
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
const COLLAPSE_BELOW = 1250
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
const wideRow = $derived(wide && roomInBar)
@@ -172,7 +182,7 @@
bind:labels
{inheritedLabels}
{onSaved}
kind={errorHandlerKind}
kind={renameKind}
compact
/>
{/snippet}
@@ -245,6 +255,7 @@
item={{ kind: errorHandlerKind, path, summaryContent }}
{actions}
contexts={headerContexts}
separator="always"
/>
{:else}
<!-- Nested in a session's preview panel, which has a band of its own above it naming the
@@ -263,7 +274,7 @@
bind:labels
{inheritedLabels}
{onSaved}
kind={errorHandlerKind}
kind={renameKind}
/>
</div>
{@render badges('md')}
@@ -319,6 +319,7 @@
item={{ path: pathSnapshot ?? shownPath, summaryContent: agentSummary }}
afterName={agentHint}
actions={settings}
separator="always"
/>
<div class="h-full min-h-0 flex flex-col">
<div class="flex-1 min-h-0 px-4 sm:px-6">
@@ -1,7 +1,8 @@
import { AppService, FlowService, ScriptService } from '$lib/gen'
import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import { agentEditorRefusal } from './flows/agentResourceUtils'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
type ItemKind = 'flow' | 'script' | 'app'
type ItemKind = 'flow' | 'script' | 'app' | 'agent'
/**
* Check whether a flow uses on_behalf_of_email.
@@ -71,6 +72,24 @@ export async function updateItemPathAndSummary(opts: {
skip_draft_deletion: true
}
})
} else if (kind === 'agent') {
// An agent is a resource, and its summary is the resource's description. The read before
// the write is the same one the agent editor does: an update carries no resource type, so
// a path deleted and recreated as something else in the meantime would otherwise take an
// agent's config.
const current = await ResourceService.getResource({ workspace, path: initialPath })
const refused = agentEditorRefusal(initialPath, current.resource_type)
if (refused) {
throw new Error(refused)
}
// Only what this popover edits: the backend sets a column per field it is given, so the
// agent's own config, its type and `ws_specific` are left where they are rather than read
// here and written back over whatever changed in between.
await ResourceService.updateResource({
workspace,
path: initialPath,
requestBody: { path: newPath, description: newSummary, labels }
})
} else if (kind === 'app') {
await AppService.updateApp({
workspace,
@@ -49,6 +49,13 @@ export type PageHeaderContent = {
* 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
/** The bottom edge under the bar, which the bar owns so a page cannot draw a second one.
* `onScroll` (the default) shows it only while something is passing under the bar — at the top
* of a list there is nothing to divide. `always` is for a page whose content starts right
* under the bar and needs the division at rest: an editor, a detail page, a settings pane.
* `none` is for a page that genuinely draws its own first line, such as the script editor's
* toolbar or the flow editor's canvas. */
separator?: 'onScroll' | 'always' | 'none'
/** Contexts this registration's snippets look up — its actions, what it hangs after the name,
* and its summary. They all render under the header rather than under the page that wrote
* them, so anything the page's tree provides has to travel with them. */
@@ -99,6 +106,7 @@ export const pageHeader = {
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.separator !== undefined) merged.separator = c.separator
}
return merged
},
@@ -143,6 +143,8 @@
* it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */
pendingDraftPath?: string | undefined
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
/** `Exit & see details`: see RawAppEditorHeader. */
onDetails?: (e: { path: string }) => void
onResetToDeployed?: () => void | Promise<void>
/** The app_version the draft forked from, for the deploy-time "new version
* deployed" guard: deploying is refused with a confirmation while it is not
@@ -212,6 +214,7 @@
autosavePath = undefined,
defaultSplitWithPreview = true,
pendingDraftPath = $bindable(undefined),
onDetails,
onResetToDeployed,
loadedFromDraft = false,
othersDraftsCount = 0,
@@ -2445,6 +2448,7 @@
{getBundle}
{onNavigate}
{onDeploy}
{onDetails}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
@@ -186,6 +186,9 @@
pendingDraftPath?: string | undefined
// Threaded to the `AutosaveIndicator` popover so its "Reset to
// deployed" button can do the same thing the load-time toast offers.
/** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that
* page itself, so it flips the tab there rather than navigating out of the session. */
onDetails?: (e: { path: string }) => void
onResetToDeployed?: () => void | Promise<void>
// See ScriptBuilderProps — same semantics for the raw-app editor's
// indicator.
@@ -236,6 +239,7 @@
autosavePath = undefined,
onDeploy = undefined,
pendingDraftPath = $bindable(undefined),
onDetails,
onResetToDeployed,
loadedFromDraft = false,
othersDraftsCount = 0,
@@ -935,7 +939,9 @@
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the app's path and summary are
the breadcrumb's, and everything else the bar carried rides along as the header's actions. -->
the breadcrumb's, and everything else the bar carried rides along as the header's actions.
`separator="none"`: the splitpanes below draw their own top line, and the bar's edge would
be a second one on top of it. -->
<PageHeaderContent
item={{
// The path being edited, not the stored one: a brand-new app is parked at a
@@ -948,6 +954,7 @@
}}
actions={rawAppHeaderActions}
contexts={headerContexts}
separator="none"
/>
{:else}
<div
@@ -1139,6 +1146,16 @@
variant="accent"
dropdownItems={appPath != ''
? () => [
// `appPath`, not the live edited path: a rename that has not been deployed yet
// would land on a details page for something that does not exist.
...(newApp
? []
: [
{
label: 'Exit & see details',
onClick: () => onDetails?.({ path: appPath })
}
]),
{
label: 'Fork',
onClick: () => {
@@ -361,6 +361,7 @@
path={itemPath}
{workspaceId}
{onNavigate}
onSeeDetails={seeDetails}
{isActiveSession}
{active}
/>
@@ -20,6 +20,7 @@
path,
workspaceId,
onNavigate,
onSeeDetails,
isActiveSession = true,
active = true
}: {
@@ -27,6 +28,9 @@
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/** `Exit & see details` — flips this tab to the item's deployed view rather than
* navigating out of the session. */
onSeeDetails?: (e: { path: string }) => void
/** Forwarded to SessionEditorTarget — only the visible session claims the
* workspace's single live-editor slot. */
isActiveSession?: boolean
@@ -272,6 +276,7 @@
newApp={!cell.saved.val || cell.saved.val.no_deployed === true}
{diffDrawer}
{onNavigate}
onDetails={onSeeDetails}
condensedHeader={true}
onResetToDeployed={reloadDeployed}
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', e.path)}
@@ -4,10 +4,10 @@ import type { UserWorkspace } from '$lib/stores'
/**
* An `operator_settings` key that admits an operator to one page (`triggers` to all of them).
*
* Only the visibility flags: the same object also carries write rights (`builder_flows`,
* `manage_schedules`, `manage_triggers`) and the fork comparison preferences, and neither answers
* to the `=== true` rule below. A write right is granted unless withdrawn, so its absence means
* "never configured" rather than "no" — see `docs/operator-write-rights.md`.
* Only the visibility flags: the same object also carries the write rights `builder_flows`,
* `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A
* write right is granted unless withdrawn, so its absence means "never configured" rather than
* "no" — see `docs/operator-write-rights.md`.
*/
export type OperatorPageKey = Extract<
keyof NonNullable<OperatorSettings>,
@@ -19,7 +19,6 @@
isSessionPreviewFrame,
rememberMenuHidden
} from '$lib/components/sessions/sessionMode.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
import SidebarUsage from '$lib/components/sidebar/SidebarUsage.svelte'
@@ -1349,12 +1348,16 @@
></div>
{/if}
{#if useDrawer}
<!-- `inert` while closed, not only `pointer-events-none`: the card stays mounted so it
can slide rather than pop, and a card parked off the left edge still holds every
one of its links in the tab order. -->
<div
class={classNames(
'relative',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
inert={!menuOpen}
role="dialog"
>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
@@ -1407,101 +1410,6 @@
{@render sidebarColumn(false)}
</div>
{/if}
<!-- Legacy menu -->
<div
class={classNames(
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300',
'opacity-0 pointer-events-none'
)}
>
<div class={twMerge('fixed inset-0 flex ', '-z-0')}>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-100 transform',
'-translate-x-full'
)}
>
<div
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-100',
'opacity-0'
)}
>
<button
type="button"
onclick={() => {
// menuSlide = !menuSlide
}}
aria-label="Close"
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
<svg
class="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="h-full" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<div
class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-10"
class:w-40={!isCollapsed}
>
<WindmillIcon white={darkMode} height="20px" width="20px" />
{#if !isCollapsed}{#if $whitelabelNameStore}{capitalize(
$whitelabelNameStore
)}{:else}Windmill{/if}{/if}
</div>
<div class="px-2 py-4 space-y-2 border-y border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
{#if !$copilotInfo.workspaceDisabled}
<MenuButton
stopPropagationOnClick={true}
on:click={() => aiChatManager.toggleOpen()}
{isCollapsed}
icon={WandSparkles}
iconProps={{
forceDarkMode: true
}}
label="Ask AI"
class="!text-xs"
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{/if}
</div>
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
</div>
</div>
</div>
</div>
{/if}
<div class="flex flex-col h-full w-full relative">
<!-- Not gated on `$enterpriseLicense`: it paints the cached accent before the license
@@ -27,6 +27,8 @@
let path = $derived(page.params.path ?? '')
let ws = $derived($workspaceStore)
/** Bumped to read the agent again after it was renamed in place. */
let reloaded = $state(0)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let agent = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
@@ -89,9 +91,18 @@
<main class="h-full w-full flex flex-col">
<DetailPageHeader
ownsPageHeader
itemKind="agent"
summary={config ? agent?.state?.description : undefined}
{path}
{menuItems}
onSaved={canEdit
? async (newPath) => {
// A rename moves the page; a summary saved in place has to be read again, since
// what is shown here came from the load this page keys on.
if (newPath !== path) await goto(`${base}/agents/get/${newPath}`)
else reloaded++
}
: undefined}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is.
...(canEdit
@@ -155,7 +166,7 @@
{/snippet}
</DetailPageHeader>
<div class="flex-1 min-h-0">
{#key `${ws}:${path}`}
{#key `${ws}:${path}:${reloaded}`}
<AgentEditorHost
bind:this={host}
{path}
@@ -607,6 +607,7 @@
<RawAppEditor
bind:this={rawAppEditor}
ownsPageHeader
onDetails={(e) => goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)}
onSavedNewAppPath={(savedPath) => {
draftSync.remove()
goto(`/apps_raw/edit/${savedPath}`)
@@ -112,7 +112,7 @@
<div class="flex flex-col w-full h-full">
<!-- `afterName`, not an action: the hint explains what audit logs are, so it belongs beside
the page's name rather than at the far end of the bar with the filters. -->
<PageHeaderContent afterName={auditHint} actions={auditActions} />
<PageHeaderContent afterName={auditHint} actions={auditActions} actionsFlexible />
{#snippet auditHint()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/audit_logs">
@@ -121,7 +121,10 @@
{/snippet}
{#snippet auditActions()}
<div class="hidden 2xl:block">
<!-- Six labelled filters are wider than any bar, so the set scrolls inside its own box
rather than pushing the last ones off the end. `actionsFlexible` on the registration
is what lets this box shrink far enough for that to matter. -->
<div class="hidden 2xl:block min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap">
<AuditLogsFilters
{logs}
bind:username
@@ -4,8 +4,9 @@
import PipelineSetupSignpost from '$lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte'
import PipelineAlphaAckModal from '$lib/components/assets/AssetGraph/PipelineAlphaAckModal.svelte'
import PipelineDbtSignpost from '$lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte'
import { BookOpen, NetworkIcon } from 'lucide-svelte'
import { BookOpen } from 'lucide-svelte'
import { onMount } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
const ACK_STORAGE_KEY = 'pipeline-alpha-ack'
@@ -36,21 +37,19 @@
<title>Pipelines — Windmill</title>
</svelte:head>
<div class="flex flex-col h-full">
<div
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex flex-row items-center gap-2">
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">Pipelines</h1>
<span
class="text-2xs px-1.5 py-0.5 rounded font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
Alpha
</span>
</div>
</div>
<!-- No title row of its own: the band already names this page, and the badge that sat beside the
title rides after that name. -->
<PageHeaderContent section={{ label: 'Pipelines' }} afterName={alphaBadge} separator="always" />
{#snippet alphaBadge()}
<span
class="text-2xs px-1.5 py-0.5 rounded font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
Alpha
</span>
{/snippet}
<div class="flex flex-col h-full">
<div class="flex-1 min-h-0 overflow-y-auto bg-surface-secondary">
<div class="max-w-2xl mx-auto flex flex-col gap-6 px-4 py-8">
<div class="flex flex-col gap-2">
@@ -13,7 +13,7 @@
import ErrorOrRecoveryHandler, {
handlerFullPath
} from '$lib/components/ErrorOrRecoveryHandler.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -1461,22 +1461,17 @@
<CenteredPage wrapperClasses="pb-0 h-full" handleOverflow={false} class="flex flex-col h-full">
{#if canAdmin || isForkOwner}
<PageHeader title="Workspace settings: {$workspaceStore}">
{#snippet titleActions()}
{#if $workspaceStore}
<CopyButton value={$workspaceStore} title={`Copy id: ${$workspaceStore}`} />
{/if}
{/snippet}
{#if $superadmin}
<Button variant="default" size="sm" on:click={() => goto('#superadmin-settings')}>
Instance settings
</Button>
{/if}</PageHeader
>
<!-- No title of its own: the band already says which workspace's settings these are, and the
two controls that sat beside the title are the page's actions. The panes below scroll
inside themselves, so the bar keeps its edge at rest rather than only under a scroll. -->
<PageHeaderContent actions={settingsActions} separator="always" />
<div class="flex grow min-h-0 pb-4">
<!-- Sidebar Navigation -->
<div class="w-60 shrink-0 h-full overflow-auto pb-4 pr-4 -ml-2">
<!-- The top padding belongs inside both scrollers, not above them: it is the gap under the
separator at rest, and it has to travel with the content so what scrolls passes under
the line rather than stopping short of it. -->
<div class="w-60 shrink-0 h-full overflow-auto pt-6 pb-4 pr-4 -ml-2">
<SidebarNavigation
groups={navigationGroups}
selectedId={tab}
@@ -1491,7 +1486,7 @@
<!-- Main Content -->
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto">
<div class="h-fit px-6" style="scrollbar-gutter: stable both-edges;">
<div class="h-fit px-6 pt-6" style="scrollbar-gutter: stable both-edges;">
{#if !loadedSettings}
<Skeleton layout={[1, [40]]} />
{:else if tab == 'users'}
@@ -2435,5 +2430,16 @@ export async function main(
tabMode={true}
/>
{#snippet settingsActions()}
{#if $workspaceStore}
<CopyButton value={$workspaceStore} title={`Copy id: ${$workspaceStore}`} />
{/if}
{#if $superadmin}
<Button variant="default" unifiedSize="sm" on:click={() => goto('#superadmin-settings')}>
Instance settings
</Button>
{/if}
{/snippet}
<style>
</style>