mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
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:
co-authored by
Claude Opus 5
parent
faa608dc69
commit
666a2feb52
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user