mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
Merge branch 'main' into enable-operator-ai-chat-nav
Five files conflicted, all around the detail pages and the app viewer: - DetailPageHeader: main's #11458 renders a flow's and a script's detail page inside a session's preview pane, which has a band of its own. The header therefore claims the page header only on the route that owns it (`ownsPageHeader`), and collapses on either reason — the host saying its box is narrow, or the band measuring itself below 1150px. - DetailPageLayout: took main's, which makes the same `h-full` change and adds the `wide` snippet parameter the header now reads. - InWorkspaceAppViewer, PublicApp, sessions/+page: main's per-workspace membership, deployed-tab reloads and tab-strip padding, beside this branch's band registration and Edit placement. Two changes in the same files ride along, since they cannot be separated from the resolution: - the deployed app's header is an ordinary band again — the hover reveal, the corner handle and the pin preference are gone, and a deployed raw app now sits under a 44px band like every other page. - the agent editor carries the same pen and popover as the script, flow and app editors (summary and resource path), and an agent's deployed page claims the band instead of drawing a second header row under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,38 @@
|
||||
*/
|
||||
let useClientCredentials = $state(false)
|
||||
|
||||
/** Slack v2 grants a user token for `user_scope` and a bot token for `scope`,
|
||||
* from the same app: the registry's `user_scopes` offers the choice. */
|
||||
let useUserToken = $state(false)
|
||||
/** Both kinds share one resource type, so the default path and description tell them apart. */
|
||||
let tokenKind = $derived(
|
||||
registryEntry()?.user_scopes && !useClientCredentials
|
||||
? useUserToken
|
||||
? 'user'
|
||||
: 'bot'
|
||||
: undefined
|
||||
)
|
||||
let defaultName = $derived(tokenKind ? `${resourceType}_${tokenKind}` : resourceType)
|
||||
/** Last description filled in from `tokenKind`, replaced on reconnect unless the user edited it. */
|
||||
let generatedDescription = ''
|
||||
|
||||
function fillGeneratedDescription() {
|
||||
if (description === generatedDescription) {
|
||||
description = ''
|
||||
}
|
||||
generatedDescription = tokenKind ? `${resourceType} ${tokenKind} token` : ''
|
||||
if (emptyString(description)) {
|
||||
description = generatedDescription
|
||||
}
|
||||
}
|
||||
|
||||
function selectUserToken(user: boolean) {
|
||||
if (user !== useUserToken) {
|
||||
scopes = user ? (registryEntry()?.user_scopes ?? []) : instanceScopes
|
||||
}
|
||||
useUserToken = user
|
||||
}
|
||||
|
||||
/**
|
||||
* Client credentials for resource-level OAuth
|
||||
*/
|
||||
@@ -241,6 +273,13 @@
|
||||
* list rather than "every other field" because most OAuth types also hold the fields of
|
||||
* another way in: ServiceNow's basic-auth password, Bitbucket's app password. */
|
||||
let resourceFields = $derived((registryEntry()?.resource_fields as string[] | undefined) ?? [])
|
||||
/** The grant's defaults are offered as checkboxes too, so unticking one drops it.
|
||||
* `scope_options` lists authorization-code scopes, invalid in a 2-legged request. */
|
||||
let scopeOptions = $derived(
|
||||
useClientCredentials
|
||||
? defaultCcScopes()
|
||||
: [...(registryEntry()?.scope_options ?? []), ...instanceScopes]
|
||||
)
|
||||
|
||||
/** Their slice of the resource type's schema, so they render with the type's own
|
||||
* descriptions; plain text inputs while the type is not synced from the hub. */
|
||||
@@ -286,6 +325,7 @@
|
||||
function resetClientCredentialsState() {
|
||||
supportsClientCredentials = false
|
||||
useClientCredentials = false
|
||||
useUserToken = false
|
||||
authCodeUnavailable = false
|
||||
ccInstanceConfigured = false
|
||||
ccBringYourOwn = false
|
||||
@@ -449,16 +489,15 @@
|
||||
|
||||
// Google's terms require its own button on the control that starts the sign-in, which is
|
||||
// the step-2 Connect: step 1 only picks a type, and a manual step 2 saves a resource
|
||||
// without ever reaching Google.
|
||||
// without ever reaching Google. Every registry provider that signs in through Google's
|
||||
// authorize endpoint counts; `google` itself is a login provider, not a registry entry.
|
||||
run(() => {
|
||||
isGoogleSignin =
|
||||
step == 2 &&
|
||||
!manual &&
|
||||
(resourceType == 'google' ||
|
||||
resourceType == 'gmail' ||
|
||||
resourceType == 'gcal' ||
|
||||
resourceType == 'gdrive' ||
|
||||
resourceType == 'gsheets')
|
||||
(registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ??
|
||||
false))
|
||||
})
|
||||
|
||||
run(() => {
|
||||
@@ -606,16 +645,18 @@
|
||||
value = data.res.access_token!
|
||||
valueToken = data.res
|
||||
responseExtra = data.extra ?? {}
|
||||
fillGeneratedDescription()
|
||||
step = 4
|
||||
// `fillPath` decides the path as surely as express does, so neither stops here.
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getScopesAndParams() {
|
||||
useUserToken = false
|
||||
if (!connects?.includes(connectClient)) {
|
||||
// No instance OAuth client (registry-declared CC-only provider):
|
||||
// defaults come from the static registry instead.
|
||||
@@ -746,9 +787,10 @@
|
||||
...tokenResponse,
|
||||
grant_type: 'client_credentials' // Mark this token as client_credentials
|
||||
}
|
||||
fillGeneratedDescription()
|
||||
step = 4
|
||||
if (fillPath || express) {
|
||||
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
||||
path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}`
|
||||
next()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -764,7 +806,11 @@
|
||||
* Opens popup for user to authenticate with OAuth provider
|
||||
*/
|
||||
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
|
||||
url.searchParams.append('scopes', scopes.join('+'))
|
||||
// An empty `scopes` still overrides the instance's bot scopes.
|
||||
url.searchParams.append('scopes', useUserToken ? '' : scopes.join('+'))
|
||||
if (useUserToken) {
|
||||
url.searchParams.append('user_scope', scopes.join(','))
|
||||
}
|
||||
if (extra_params.length > 0) {
|
||||
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
|
||||
}
|
||||
@@ -1147,8 +1193,6 @@
|
||||
if (step !== 1) return
|
||||
highlight.onKeydown(e)
|
||||
}
|
||||
|
||||
let editScopes = $state(false)
|
||||
</script>
|
||||
|
||||
{#if !express}
|
||||
@@ -1557,24 +1601,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-emphasis flex gap-4"
|
||||
>Scopes <button
|
||||
onclick={() => {
|
||||
editScopes = !editScopes
|
||||
}}><Pen size={14} /></button
|
||||
></h3
|
||||
>
|
||||
|
||||
{#if editScopes}
|
||||
<OauthScopes bind:scopes options={registryEntry()?.scope_options} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each scopes as scope}
|
||||
<div class="py-0.5 pl-2 text-xs">- {scope}</div>
|
||||
{/each}
|
||||
{#if registryEntry()?.user_scopes && !useClientCredentials}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold text-emphasis mb-1">Connect as</h3>
|
||||
<div class="flex flex-col gap-2" role="radiogroup" aria-label="Connect as">
|
||||
<RadioCard
|
||||
label="The app (bot token)"
|
||||
description="Acts as the app's bot user, in the channels it is added to."
|
||||
selected={!useUserToken}
|
||||
onSelect={() => selectUserToken(false)}
|
||||
/>
|
||||
<RadioCard
|
||||
label="Yourself (user token)"
|
||||
description="Acts as you, with access to what you can see: history, DMs and search."
|
||||
selected={useUserToken}
|
||||
onSelect={() => selectUserToken(true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-emphasis">Scopes</h3>
|
||||
<OauthScopes bind:scopes options={scopeOptions} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1588,7 +1637,7 @@
|
||||
<Label label="Path">
|
||||
<Path
|
||||
initialPath=""
|
||||
namePlaceholder={resourceType}
|
||||
namePlaceholder={defaultName}
|
||||
bind:error={pathError}
|
||||
bind:path
|
||||
kind="resource"
|
||||
|
||||
@@ -253,6 +253,9 @@
|
||||
|
||||
let ignoreValueUndefined = $state(false)
|
||||
let error: string = $state('')
|
||||
// Parse error of the JSON editor below, which already displays it. Text that does not parse
|
||||
// never reaches `value`, so without this the field would stay valid on its last parsed value.
|
||||
let jsonError: string = $state('')
|
||||
let isListJson = $state(false)
|
||||
let hasIsListJsonChanged = $state(false)
|
||||
|
||||
@@ -417,9 +420,12 @@
|
||||
'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'
|
||||
|
||||
function validateInput(pattern: string | undefined, v: any, required: boolean): void {
|
||||
if (nullable && emptyString(v)) {
|
||||
if (jsonError) {
|
||||
error = ''
|
||||
valid && (valid = true)
|
||||
valid && (valid = false)
|
||||
} else if (nullable && emptyString(v)) {
|
||||
error = ''
|
||||
!valid && (valid = true)
|
||||
} else if (
|
||||
typeof v === 'string' &&
|
||||
(v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:'))
|
||||
@@ -563,6 +569,7 @@
|
||||
|
||||
$effect(() => {
|
||||
extra?.['nonEmpty']
|
||||
jsonError
|
||||
let args = [pattern, value, required] as const
|
||||
untrack(() => validateInput(...args))
|
||||
})
|
||||
@@ -1262,6 +1269,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:changeValue={(e) => {
|
||||
setNewValueFromCode(e.detail)
|
||||
}}
|
||||
@@ -1284,6 +1292,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:change={(e) => {
|
||||
value = e.detail
|
||||
}}
|
||||
@@ -1373,6 +1382,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
bind:error={jsonError}
|
||||
on:changeValue={(e) => {
|
||||
setNewValueFromCode(e.detail)
|
||||
}}
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
'gmail',
|
||||
'gcal',
|
||||
'gforms',
|
||||
'gdocs',
|
||||
'gchat',
|
||||
'gcloud',
|
||||
'gworkspace',
|
||||
'basecamp',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type CiTestResult } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { CircleCheck, CircleX, Loader2, FlaskConical } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
let { path, kind }: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let ciTests = resource(
|
||||
() => ({ workspace: $workspaceStore!, path, kind }),
|
||||
() => ({ workspace: $operatingWorkspace!, path, kind }),
|
||||
async (args) => {
|
||||
if (!args.workspace || !args.path) return []
|
||||
return ScriptService.getCiTestResults({
|
||||
@@ -55,14 +57,14 @@
|
||||
<div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div>
|
||||
{/if}
|
||||
<a
|
||||
href="{base}/scripts/get/{test.test_script_path}?workspace={$workspaceStore}"
|
||||
href="{base}/scripts/get/{test.test_script_path}?workspace={$operatingWorkspace}"
|
||||
class="text-secondary hover:text-primary truncate"
|
||||
>
|
||||
{test.test_script_path}
|
||||
</a>
|
||||
{#if test.job_id}
|
||||
<a
|
||||
href="{base}/run/{test.job_id}?workspace={$workspaceStore}"
|
||||
href="{base}/run/{test.job_id}?workspace={$operatingWorkspace}"
|
||||
class="text-tertiary hover:text-secondary text-2xs"
|
||||
>
|
||||
view run
|
||||
|
||||
@@ -28,9 +28,13 @@ leaves and ignores the current scope.
|
||||
resolveScope,
|
||||
scopeChain,
|
||||
type DrillBranch,
|
||||
type DrillIcon,
|
||||
type DrillLeaf,
|
||||
type DrillNode
|
||||
} from './drillPicker'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import type { BadgeColor } from '$lib/components/common/badge/model'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
interface Props {
|
||||
tree: DrillNode<L>[]
|
||||
@@ -72,6 +76,18 @@ leaves and ignores the current scope.
|
||||
/** Full-text hover tooltip for a leaf row (rows truncate their text).
|
||||
* Return `undefined` to show none for that leaf. */
|
||||
rowTooltip?: (leaf: DrillLeaf<L>) => string | undefined
|
||||
/** A second thing a leaf row can do, offered on hover and on ⌘/Ctrl-pick
|
||||
* (the preview picker's "open the editor instead of the deployed page").
|
||||
* Return `undefined` for a leaf that has only one. The button renders as a
|
||||
* SIBLING of the row — a button inside a button is invalid markup — and
|
||||
* stays out of the tab order, because focus lives in the search field and
|
||||
* rows are reached with the arrow keys. */
|
||||
leafAction?: (
|
||||
leaf: DrillLeaf<L>
|
||||
) => { icon: DrillIcon; label: string; title: string; run: () => void } | undefined
|
||||
/** Status badge on the right of a leaf row. Returns `undefined` for a leaf with
|
||||
* nothing to report. The hover action, when there is one, covers it. */
|
||||
leafBadge?: (leaf: DrillLeaf<L>) => { label: string; color: BadgeColor } | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -88,7 +104,9 @@ leaves and ignores the current scope.
|
||||
onScopeChange,
|
||||
onFilterChange,
|
||||
rootLoading = false,
|
||||
rowTooltip
|
||||
rowTooltip,
|
||||
leafAction,
|
||||
leafBadge
|
||||
}: Props = $props()
|
||||
|
||||
let searchInput: TextInput | undefined = $state()
|
||||
@@ -277,8 +295,21 @@ leaves and ignores the current scope.
|
||||
if (mouseActive) highlightedKey = key
|
||||
}
|
||||
|
||||
function pick(leaf: DrillLeaf<L>) {
|
||||
if (leaf.current || leaf.disabled) return
|
||||
/** `alt` picks the row's second action (see `leafAction`) rather than the row
|
||||
* itself — ⌘/Ctrl-click and ⌘/Ctrl-Enter, the keyboard reach for the button
|
||||
* that only appears on hover. A leaf with no second action ignores it. */
|
||||
function pick(leaf: DrillLeaf<L>, alt = false) {
|
||||
if (leaf.disabled) return
|
||||
// The second action is the row's other destination (its editor, for the deployed page
|
||||
// this tab shows), so it stays available on the current row the plain pick skips.
|
||||
if (alt) {
|
||||
const action = leafAction?.(leaf)
|
||||
if (action) {
|
||||
action.run()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (leaf.current) return
|
||||
onPick(leaf)
|
||||
}
|
||||
|
||||
@@ -316,23 +347,23 @@ leaves and ignores the current scope.
|
||||
|
||||
$effect(() => () => clearTimeout(tooltipTimer))
|
||||
|
||||
function activate(key: string | undefined) {
|
||||
function activate(key: string | undefined, alt = false) {
|
||||
if (!key) return
|
||||
if (isSearching) {
|
||||
const found = (searchedItems ?? []).find((r) => r.leaf.key === key)
|
||||
if (found) pick(found.leaf)
|
||||
if (found) pick(found.leaf, alt)
|
||||
return
|
||||
}
|
||||
const entry = entryList.find((e) => e.key === key)
|
||||
if (!entry) return
|
||||
drill(entry)
|
||||
drill(entry, alt)
|
||||
}
|
||||
|
||||
function drill(entry: Entry) {
|
||||
function drill(entry: Entry, alt = false) {
|
||||
if (entry.type === 'branch') {
|
||||
scope = [...scope, entry.key]
|
||||
} else {
|
||||
pick(entry.node)
|
||||
pick(entry.node, alt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +393,7 @@ leaves and ignores the current scope.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
mouseActive = false
|
||||
activate(highlightedKey)
|
||||
activate(highlightedKey, e.metaKey || e.ctrlKey)
|
||||
} else if (
|
||||
(e.key === 'ArrowLeft' || e.key === 'Backspace') &&
|
||||
filter === '' &&
|
||||
@@ -476,42 +507,97 @@ leaves and ignores the current scope.
|
||||
{@const isHl = key === highlightedKey}
|
||||
{@const isCur = !!leaf.current}
|
||||
{@const tip = rowTooltip?.(leaf)}
|
||||
<button
|
||||
type="button"
|
||||
id={idFor(key)}
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
|
||||
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
|
||||
{@const action = leaf.disabled ? undefined : leafAction?.(leaf)}
|
||||
{@const badge = leafBadge?.(leaf)}
|
||||
<!-- The row's action sits BESIDE the option rather than inside it: a button nested in
|
||||
a button is invalid markup. The wrapper carries the row's padding, highlight and
|
||||
hover group so the two still read as one row. -->
|
||||
<!-- Hover is tracked on the wrapper, not on the option button: the row's action is a
|
||||
sibling of that button, so entering it would otherwise read as leaving the row and
|
||||
drop the highlight out from under the pointer. -->
|
||||
<div
|
||||
class="group flex items-center transition-colors pr-3 {baseClass} {isHl
|
||||
? 'bg-surface-hover'
|
||||
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''} {leaf.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
disabled={leaf.disabled}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => pick(leaf)}
|
||||
onmouseenter={(e) => {
|
||||
setHoverHighlight(key)
|
||||
tooltipEnter(e.currentTarget as HTMLElement, leaf)
|
||||
}}
|
||||
onmouseleave={tooltipLeave}
|
||||
>
|
||||
{@render defaultLeafIcon(leaf)}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if leaf.secondary}
|
||||
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
|
||||
<div class="text-2xs text-hint font-normal font-mono truncate">
|
||||
{secondary ?? leaf.secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-normal font-mono truncate">
|
||||
{secondary ?? leaf.label}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id={idFor(key)}
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
|
||||
class="min-w-0 flex-1 text-left flex items-center gap-2 pl-3 {isCur
|
||||
? 'cursor-default text-emphasis font-medium'
|
||||
: ''} {leaf.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
disabled={leaf.disabled}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={(e) => pick(leaf, e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
{@render defaultLeafIcon(leaf)}
|
||||
<!-- Centred in the row's full height so a one-line leaf sits where a two-line
|
||||
one's pair does, rather than riding high against the top. -->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-center min-h-[2.25rem]">
|
||||
{#if leaf.secondary}
|
||||
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
|
||||
<div class="text-2xs text-hint font-normal font-mono truncate">
|
||||
{secondary ?? leaf.secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-normal font-mono truncate">
|
||||
{secondary ?? leaf.label}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<!-- Keyed on the props rather than on this leaf's own badge or action, so a picker
|
||||
that has either holds the column open on every row: paths then truncate at one
|
||||
column and the button lands in the same place down the list. -->
|
||||
{#if leafBadge || leafAction}
|
||||
<!-- Both sit in one grid cell so they cross-fade in place rather than sitting
|
||||
side by side or shifting the row as one replaces the other. -->
|
||||
<div class="shrink-0 w-[4.5rem] grid justify-items-end items-center pl-2">
|
||||
{#if action}
|
||||
<!-- `invisible`, not `hidden`: display cannot be transitioned, while
|
||||
visibility both fades with opacity and keeps the button out of the tab
|
||||
order while it is out of sight — the picker's focus belongs to its
|
||||
search field, and rows are reached with the arrow keys.
|
||||
The classes sit on this wrapper, not on the Button: `wrapperClasses` is
|
||||
twMerged with the button's own `flex`, which drops a conflicting
|
||||
display and leaves it showing beside the badge. -->
|
||||
<div
|
||||
class="col-start-1 row-start-1 invisible opacity-0 transition-[opacity,visibility] duration-150 group-hover:visible group-hover:opacity-100"
|
||||
>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
title={action.title}
|
||||
startIcon={{ icon: action.icon }}
|
||||
onClick={action.run}
|
||||
wrapperClasses="shrink-0"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if badge}
|
||||
<div
|
||||
class="col-start-1 row-start-1 transition-[opacity,visibility] duration-150 {action
|
||||
? 'group-hover:invisible group-hover:opacity-0'
|
||||
: ''}"
|
||||
>
|
||||
<Badge small color={badge.color}>{badge.label}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
@@ -1831,9 +1831,15 @@
|
||||
scriptLang === 'bunnative' ||
|
||||
scriptLang === 'nativets')
|
||||
) {
|
||||
const resourceTypes = await ResourceService.listResourceType({
|
||||
workspace: $operatingWorkspace ?? ''
|
||||
})
|
||||
// The generated client resolves `undefined` for any response whose body it
|
||||
// cannot read — `getResponseBody` swallows a parse failure and returns
|
||||
// nothing, which `catchErrorCodes` passes through on a 2xx. Typing says
|
||||
// otherwise, so without this the editor dies on mount rather than losing
|
||||
// its resource-type autocomplete.
|
||||
const resourceTypes =
|
||||
(await ResourceService.listResourceType({
|
||||
workspace: $operatingWorkspace ?? ''
|
||||
})) ?? []
|
||||
|
||||
const namespace = formatResourceTypes(
|
||||
resourceTypes,
|
||||
|
||||
@@ -1136,10 +1136,11 @@
|
||||
onClick: () => void
|
||||
}> = []
|
||||
|
||||
// In a session pane every one of these leaves the session (details page, new
|
||||
// tab), so the deploy button carries no dropdown there — as in ScriptBuilder.
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) {
|
||||
if (!newFlow) {
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
||||
// The one option that stays in a session pane: the session hosts the details page
|
||||
// itself, so this flips the tab to it rather than leaving the session. Everything
|
||||
// below opens a details page or a new tab outside the session, so it stays hidden.
|
||||
if (!newFlow && savedFlow?.no_deployed !== true) {
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
@@ -1149,7 +1150,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (!untrack(() => newFlow)) {
|
||||
if (!untrack(() => newFlow) && !inSessionPane) {
|
||||
dropdownItems.push({
|
||||
label: 'Fork',
|
||||
onClick: () => window.open(`/flows/add?template=${initialPath}`)
|
||||
@@ -1158,6 +1159,7 @@
|
||||
|
||||
if (
|
||||
!untrack(() => newFlow) &&
|
||||
!inSessionPane &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(opWorkspace, $userWorkspaces)
|
||||
) {
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
// Identity for this editor's entry in the unparseable registry, so a caller about to
|
||||
// persist what is on screen can refuse rather than save the last value that parsed.
|
||||
const unparseableKey = {}
|
||||
onDestroy(() => setEditorUnparseable(unparseableKey, false))
|
||||
// Also clears a bound `error`: a parent gating validity on it would otherwise stay invalid
|
||||
// after the editor that reported the error is gone.
|
||||
onDestroy(() => {
|
||||
error = ''
|
||||
setEditorUnparseable(unparseableKey, false)
|
||||
})
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
||||
@@ -57,13 +62,17 @@
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
setEditorUnparseable(unparseableKey, error !== '')
|
||||
setEditorUnparseable(unparseableKey, error !== '', rootEl)
|
||||
}
|
||||
let rootEl: HTMLElement | undefined = $state()
|
||||
$effect(() => {
|
||||
code != undefined && untrack(() => parseJson())
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Wraps both branches: the unparseable registry locates this editor by it, and the
|
||||
too-big placeholder must not drop an invalid editor out of its form's run gate. -->
|
||||
<div bind:this={rootEl} class="contents">
|
||||
{#if tooBig && !loadTooBigAnyway}
|
||||
<div class="flex-1 text-sm">
|
||||
JSON is too big
|
||||
@@ -100,3 +109,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { setEditorUnparseable } from './pendingEditorFlush'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -31,6 +32,19 @@
|
||||
let userEdited = false
|
||||
let simpleEditor: SimpleEditor | undefined = $state(undefined)
|
||||
let focusTrap: HTMLElement | undefined = $state()
|
||||
let parseError = $state('')
|
||||
|
||||
// Text that does not parse dispatches `undefined`, which callers read as "keep the last args":
|
||||
// registering it lets whoever runs or persists those args refuse instead of using stale ones.
|
||||
const unparseableKey = {}
|
||||
onDestroy(() => setEditorUnparseable(unparseableKey, false))
|
||||
|
||||
let rootEl: HTMLElement | undefined = $state()
|
||||
|
||||
function setParseError(message: string) {
|
||||
parseError = message
|
||||
setEditorUnparseable(unparseableKey, message !== '', rootEl)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const next = initialCode
|
||||
@@ -48,17 +62,21 @@
|
||||
userEdited = false
|
||||
pendingJson = code
|
||||
simpleEditor?.setCode(code)
|
||||
setParseError('')
|
||||
}
|
||||
|
||||
function updatePayloadFromJson(jsonInput: string) {
|
||||
if (jsonInput === undefined || jsonInput === null || jsonInput.trim() === '') {
|
||||
setParseError('')
|
||||
dispatch('select', undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput)
|
||||
setParseError('')
|
||||
dispatch('select', parsed)
|
||||
} catch (error) {
|
||||
setParseError(error instanceof Error ? error.message : String(error))
|
||||
dispatch('select', undefined)
|
||||
}
|
||||
}
|
||||
@@ -88,31 +106,36 @@
|
||||
<!-- Add a hidden button that can receive focus -->
|
||||
<button bind:this={focusTrap} class="sr-only" tabindex="-1" aria-hidden="true">Focus trap</button>
|
||||
|
||||
<div class="h-full rounded-md border">
|
||||
<SimpleEditor
|
||||
bind:this={simpleEditor}
|
||||
on:input={() => (userEdited = true)}
|
||||
on:focus={() => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('focus')
|
||||
updatePayloadFromJson(pendingJson)
|
||||
}
|
||||
}}
|
||||
on:blur={async () => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('blur')
|
||||
}
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (e.detail?.code !== undefined) {
|
||||
updatePayloadFromJson(e.detail.code)
|
||||
}
|
||||
}}
|
||||
bind:code={pendingJson}
|
||||
lang="json"
|
||||
class="h-full json-inputs-editor"
|
||||
{placeholder}
|
||||
/>
|
||||
<div bind:this={rootEl} class="h-full flex flex-col">
|
||||
<div class="flex-1 min-h-0 rounded-md border">
|
||||
<SimpleEditor
|
||||
bind:this={simpleEditor}
|
||||
on:input={() => (userEdited = true)}
|
||||
on:focus={() => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('focus')
|
||||
updatePayloadFromJson(pendingJson)
|
||||
}
|
||||
}}
|
||||
on:blur={async () => {
|
||||
if (updateOnBlur) {
|
||||
dispatch('blur')
|
||||
}
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (e.detail?.code !== undefined) {
|
||||
updatePayloadFromJson(e.detail.code)
|
||||
}
|
||||
}}
|
||||
bind:code={pendingJson}
|
||||
lang="json"
|
||||
class="h-full json-inputs-editor"
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
{#if parseError}
|
||||
<span class="text-red-600 dark:text-red-400 text-xs mt-1">Invalid JSON: {parseError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace, useOperatingUser } from './operatingWorkspace.svelte'
|
||||
import { Alert, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Path from './Path.svelte'
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// A move is a write, so it has to land in the workspace the host acts on: inside an AI
|
||||
// session's editor or deployed view that is the session's (possibly forked) workspace,
|
||||
// while the navigation store still names the parent.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
|
||||
|
||||
/** The address the move endpoint takes: where a draft-only item's row lives, which
|
||||
@@ -30,7 +36,13 @@
|
||||
|
||||
let drawer = $state<Drawer>() as Drawer
|
||||
|
||||
let own = $state(false)
|
||||
// Derived rather than read when the drawer opens: the acting user in another workspace is
|
||||
// looked up asynchronously, so a snapshot taken at open time says "not owner" for a fork.
|
||||
const ownerKnown = $derived(operatingUser.resolved($operatingWorkspace))
|
||||
const own = $derived(
|
||||
!!operatingUser.current &&
|
||||
isOwner(draftOnly ? storagePath : initialPath, operatingUser.current, $operatingWorkspace!)
|
||||
)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
// Counts of triggers/schedules/etc. that reference this script or flow.
|
||||
// The backend cascades `script_path` on rename across all trigger tables
|
||||
@@ -93,13 +105,12 @@
|
||||
initialPath = initialPath_l
|
||||
initialSummary = summary_l ?? ''
|
||||
summary = summary_l
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
if (draftOnly) {
|
||||
return
|
||||
}
|
||||
if (kind === 'flow') {
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($operatingWorkspace!, initialPath_l)
|
||||
}
|
||||
if (kind === 'script' || kind === 'flow') {
|
||||
void loadAttachedTriggers()
|
||||
@@ -108,7 +119,7 @@
|
||||
|
||||
async function loadAttachedTriggers() {
|
||||
try {
|
||||
const workspace = $workspaceStore!
|
||||
const workspace = $operatingWorkspace!
|
||||
attachedTriggers =
|
||||
kind === 'flow'
|
||||
? await FlowService.getTriggersCountOfFlow({ workspace, path: initialPath })
|
||||
@@ -119,21 +130,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadOwner() {
|
||||
own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
|
||||
await DraftService.moveDraft({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
|
||||
path: storagePath,
|
||||
requestBody: { new_path: path ?? '', summary: summary ?? '' }
|
||||
})
|
||||
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
kind,
|
||||
initialPath,
|
||||
newPath: path ?? '',
|
||||
@@ -147,14 +154,16 @@
|
||||
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Move/Rename {initialPath}" on:close={drawer.closeDrawer}>
|
||||
{#if !own}
|
||||
<!-- Only once the acting user is known: while the lookup is in flight `own` is false,
|
||||
and stating "you do not own this" then would be a guess. -->
|
||||
{#if ownerKnown && !own}
|
||||
<Alert type="warning" title="Not owner" class="mb-4">
|
||||
Since you do not own this item, you cannot move this item (you can however fork it)
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if own && onBehalfOfEmail}
|
||||
<Alert type="info" title="Run on behalf of" class="mb-4">
|
||||
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
|
||||
This flow will be redeployed on behalf of you ({operatingUser.current?.email}) instead of {onBehalfOfEmail}
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if (kind === 'script' || kind === 'flow') && attachedTotal > 0}
|
||||
|
||||
@@ -6,13 +6,27 @@
|
||||
|
||||
interface Props {
|
||||
scopes?: string[]
|
||||
/** Scopes the provider is known to accept, offered as checkboxes. Anything
|
||||
* not in this list stays editable as free text below them. */
|
||||
options?: string[]
|
||||
/** Scopes the provider is known to accept, offered as checkboxes, either a
|
||||
* bare scope or one with a human label. Anything not in this list stays
|
||||
* editable as free text below them. */
|
||||
options?: (string | { value: string; label: string })[]
|
||||
}
|
||||
|
||||
let { scopes = $bindable(), options = [] }: Props = $props()
|
||||
|
||||
// First occurrence wins, so a labelled option keeps its label when the same
|
||||
// scope is also passed bare (e.g. as an instance default).
|
||||
let choices = $derived(
|
||||
options
|
||||
.map((o) =>
|
||||
typeof o == 'string'
|
||||
? { value: o, label: o }
|
||||
: { value: o.value, label: o.label || o.value }
|
||||
)
|
||||
.filter((c, i, all) => all.findIndex((d) => d.value == c.value) == i)
|
||||
)
|
||||
let values = $derived(choices.map((c) => c.value))
|
||||
|
||||
// Ticked options and free-text rows are kept apart from `scopes` (the only
|
||||
// value the parent binds) so a row can pass through an option's exact value
|
||||
// while typing (`…/calendar` on the way to `…/calendar.acls`) without ticking
|
||||
@@ -26,11 +40,11 @@
|
||||
if (!scopes) {
|
||||
scopes = []
|
||||
}
|
||||
const json = JSON.stringify([scopes, options])
|
||||
const json = JSON.stringify([scopes, values])
|
||||
if (json != lastWritten) {
|
||||
lastWritten = json
|
||||
ticked = scopes.filter((v) => options.includes(v))
|
||||
custom = scopes.filter((v) => !options.includes(v))
|
||||
ticked = scopes.filter((v) => values.includes(v))
|
||||
custom = scopes.filter((v) => !values.includes(v))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,7 +52,7 @@
|
||||
ticked = nextTicked
|
||||
custom = rows
|
||||
scopes = [...nextTicked.filter((o) => !rows.includes(o)), ...rows]
|
||||
lastWritten = JSON.stringify([scopes, options])
|
||||
lastWritten = JSON.stringify([scopes, values])
|
||||
}
|
||||
|
||||
// Ticking an option absorbs a free-text row holding the same value. The
|
||||
@@ -59,17 +73,23 @@
|
||||
|
||||
{#if options.length > 0}
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
{#each options as option (option)}
|
||||
{#each choices as { value, label } (value)}
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={ticked.includes(option)}
|
||||
onChange={() => toggle(option, !ticked.includes(option))}
|
||||
checked={ticked.includes(value)}
|
||||
onChange={() => toggle(value, !ticked.includes(value))}
|
||||
/>
|
||||
<span class="font-mono break-all">{option}</span>
|
||||
{#if label == value}
|
||||
<span class="font-mono break-all">{value}</span>
|
||||
{:else}
|
||||
<span class="flex flex-col">
|
||||
<span>{label}</span>
|
||||
<span class="text-2xs text-hint font-mono break-all">{value}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-secondary">Custom scopes</span>
|
||||
{/if}
|
||||
|
||||
{#each custom as v, i (i)}
|
||||
@@ -104,11 +124,6 @@
|
||||
write(ticked, [...custom, ''])
|
||||
}}
|
||||
>
|
||||
Add item
|
||||
Add scope
|
||||
</Button>
|
||||
{#if custom.length > 0}
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({custom.length} item{custom.length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ section name — and the route's own buttons at the far end.
|
||||
The row's height matches the sidebar's own header row, so the two read as one band.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PanelLeft, PanelTop, PanelTopDashed } from 'lucide-svelte'
|
||||
import { PanelLeft } from 'lucide-svelte'
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
@@ -18,24 +18,12 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
|
||||
let {
|
||||
navHidden = false,
|
||||
hideNavHandle = false,
|
||||
panelled = false,
|
||||
onPin,
|
||||
onUnpin
|
||||
panelled = false
|
||||
}: {
|
||||
navHidden?: boolean
|
||||
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
|
||||
* along with this band — two handles for one gesture would be one too many. */
|
||||
hideNavHandle?: boolean
|
||||
/** The sidebar is a panel over the page rather than a rail beside it — detached, or a
|
||||
* window too narrow to seat one. Either way this bar carries the switch that opens it. */
|
||||
panelled?: boolean
|
||||
/** Keeps the band on a page that owns the viewport. The corner handle that called the band
|
||||
* down is underneath it by the time the pointer arrives, so this bar has to carry the pin
|
||||
* itself — it lands on the same pixels, in place of the sidebar handle it hides. */
|
||||
onPin?: () => void
|
||||
/** Sends the band back behind its handle, on a page that owns the viewport. */
|
||||
onUnpin?: () => void
|
||||
} = $props()
|
||||
|
||||
const content = $derived(pageHeader.content)
|
||||
@@ -99,37 +87,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
'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))]'
|
||||
)}
|
||||
>
|
||||
<!-- Pin before the sidebar's handle, so it holds the first slot whether or not the handle is
|
||||
there. Pinning brings the handle back — the band is no longer hidden — and from behind the
|
||||
handle the unpin button would sit one slot over from where the pin was just clicked, which
|
||||
for an operator (detached by default) means the second click opens the sidebar instead. -->
|
||||
{#if onPin}
|
||||
<!-- Same box and offsets as the corner handle it comes to rest over, so the pointer that
|
||||
called the band down is already on this button. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Pin the header on deployed apps"
|
||||
title="Pin the header on deployed apps"
|
||||
onclick={() => onPin?.()}
|
||||
>
|
||||
<PanelTop size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onUnpin}
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Unpin the header from deployed apps"
|
||||
title="Unpin the header from deployed apps"
|
||||
onclick={() => onUnpin?.()}
|
||||
>
|
||||
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
|
||||
toggle uses, so the two controls read as one idea. -->
|
||||
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if (navDetached.val || panelled) && !navHidden && !hideNavHandle}
|
||||
{#if (navDetached.val || panelled) && !navHidden}
|
||||
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
|
||||
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
|
||||
detaching it happened — a control that hides the thing it sits on cannot also be the
|
||||
|
||||
@@ -19,8 +19,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsFlexible,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
barRightInset
|
||||
}: PageHeaderContent = $props()
|
||||
|
||||
onMount(() => {
|
||||
@@ -33,8 +32,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsFlexible,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
barRightInset
|
||||
}))
|
||||
return () => pageHeader.release(id)
|
||||
})
|
||||
|
||||
@@ -7,13 +7,13 @@ popover is open this holds the path as it was when it opened, so the trail — a
|
||||
to it — does not reflow under the pointer as the user types, which floating-ui would follow.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
@@ -36,7 +36,10 @@ to it — does not reflow under the pointer as the user types, which floating-ui
|
||||
path?: string
|
||||
/** The item's *saved* path on the server, so the popover can say a rename needs deploying. */
|
||||
savedPath?: string
|
||||
kind?: WorkspaceItemKind
|
||||
/** Scopes the path picker's validation, and names the item in the placeholder and the
|
||||
* deploy note. An agent lives at a resource path, so this is wider than the three
|
||||
* workspace item kinds. */
|
||||
kind?: ComponentProps<typeof Path>['kind']
|
||||
/** Workspace the path picker scopes to; defaults to the operating workspace. */
|
||||
workspaceId?: string
|
||||
/** When set, warns that a redeploy happens on behalf of the current user instead. */
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { Badge, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { displayDate, sleep, sendUserToast } from '$lib/utils'
|
||||
import TableCustom from './TableCustom.svelte'
|
||||
import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let dispatch = createEventDispatcher()
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -38,7 +40,7 @@
|
||||
const timeStart = new Date().getTime()
|
||||
queuedJobsLoading = true
|
||||
let qjs = await JobService.listQueue({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
orderDesc: false,
|
||||
scriptPathExact: script?.path
|
||||
})
|
||||
@@ -70,7 +72,7 @@
|
||||
async function scaleToZero() {
|
||||
cancellingInProgress = true
|
||||
await JobService.cancelPersistentQueuedJobs({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
path: script?.path ?? '',
|
||||
requestBody: {
|
||||
reason: undefined
|
||||
@@ -139,7 +141,7 @@
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={$workspaceStore}"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={$operatingWorkspace}"
|
||||
target="_blank"
|
||||
>
|
||||
{scriptHash}
|
||||
@@ -148,7 +150,7 @@
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/run/{jobId}?workspace={$workspaceStore}"
|
||||
href="{base}/run/{jobId}?workspace={$operatingWorkspace}"
|
||||
target="_blank">{jobId.substring(24)}</a
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -22,10 +22,18 @@
|
||||
import { argsToJsonPayload } from '$lib/schema'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { processSecretArgs } from './secretArgUtils'
|
||||
import { enforceDisabledDefaults, resetKeysToast } from './job_args'
|
||||
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
|
||||
import { anyEditorUnparseable, flushAllPendingEditorChanges } from './pendingEditorFlush'
|
||||
import { useOperatingWorkspace } from './operatingWorkspace.svelte'
|
||||
|
||||
// The ephemeral secret variable a password argument mints has to be created in the same
|
||||
// workspace the job runs in: a form embedded in a session runs the job in the session's
|
||||
// workspace, and a `$var:` minted in the navigation workspace resolves to nothing there.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
let formEl: HTMLElement | undefined = $state()
|
||||
|
||||
let reloadArgs = $state(0)
|
||||
let jsonEditor: JsonInputs | undefined = $state(undefined)
|
||||
@@ -33,6 +41,13 @@
|
||||
let showInputSelectedBadge = $state(false)
|
||||
let savedPreviousArgs: Record<string, any> | undefined = $state(undefined)
|
||||
let psCommonParams: Record<string, any> = $state({})
|
||||
// Reset on a view switch, where the editor that refused the run is gone, and on a form
|
||||
// validity change, where the field's own error and the disabled button take over.
|
||||
let blockedByUnparseable = $derived.by(() => {
|
||||
void jsonView
|
||||
void isValid
|
||||
return false
|
||||
})
|
||||
|
||||
function extractPsCommonParams(allArgs: Record<string, any>): {
|
||||
scriptArgs: Record<string, any>
|
||||
@@ -60,13 +75,22 @@
|
||||
}
|
||||
|
||||
export async function run(overrideScheduledForStr?: string | undefined | null) {
|
||||
// An editor whose text does not parse never wrote it to `args`, so running now would send
|
||||
// the last value that did parse. Flush first: a keystroke still inside the editor debounce
|
||||
// has not been parsed yet, and per-field editors parse it in an effect, hence the tick.
|
||||
flushAllPendingEditorChanges()
|
||||
await tick()
|
||||
blockedByUnparseable = anyEditorUnparseable(formEl)
|
||||
if (blockedByUnparseable) {
|
||||
return
|
||||
}
|
||||
let processedArgs: Record<string, any>
|
||||
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
|
||||
if (resetKeys.length > 0) {
|
||||
sendUserToast(resetKeysToast(resetKeys))
|
||||
}
|
||||
try {
|
||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema)
|
||||
processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace)
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to process sensitive args: ' + e, true)
|
||||
return
|
||||
@@ -123,6 +147,11 @@
|
||||
args?: Record<string, any>
|
||||
jsonView?: boolean
|
||||
isValid?: boolean
|
||||
/** Mirror the current args into the page URL's fragment, which is what makes a
|
||||
* filled-in form shareable and what `Run again` reads back. Turn off wherever this
|
||||
* form is embedded in a page that is not the runnable's own — an AI session preview
|
||||
* tab — since there the fragment would land on an unrelated URL. */
|
||||
syncArgsToUrl?: boolean
|
||||
/** Controls beside the Run button of a form that cannot schedule, in the row a
|
||||
* schedulable one gives its Advanced options. */
|
||||
actions?: import('svelte').Snippet
|
||||
@@ -145,6 +174,7 @@
|
||||
args = $bindable(),
|
||||
jsonView = false,
|
||||
isValid = $bindable(true),
|
||||
syncArgsToUrl = true,
|
||||
actions = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -167,6 +197,7 @@
|
||||
let debounced: number | undefined = undefined
|
||||
|
||||
function onArgsChange(args: any) {
|
||||
if (!syncArgsToUrl) return
|
||||
try {
|
||||
debounced && clearTimeout(debounced)
|
||||
debounced = setTimeout(() => {
|
||||
@@ -245,7 +276,7 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="max-w-3xl">
|
||||
<div bind:this={formEl} class="max-w-3xl">
|
||||
{#if detailed}
|
||||
{#if runnable}
|
||||
<div class="flex flex-row flex-wrap justify-between gap-4">
|
||||
@@ -303,6 +334,7 @@
|
||||
<JsonInputs
|
||||
bind:this={jsonEditor}
|
||||
on:select={(e) => {
|
||||
blockedByUnparseable = false
|
||||
if (e.detail) {
|
||||
args = enforceDisabledDefaults(e.detail, runnable?.schema).args
|
||||
}
|
||||
@@ -374,6 +406,7 @@
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
{@render unparseableError()}
|
||||
{#if overrideTag}
|
||||
<div class="flex-row-reverse flex w-full text-primary text-sm">
|
||||
tag override: {overrideTag}
|
||||
@@ -405,6 +438,7 @@
|
||||
</Button>
|
||||
<div>{@render actions()}</div>
|
||||
</div>
|
||||
{@render unparseableError()}
|
||||
{:else}
|
||||
<Button
|
||||
btnClasses="!px-6 !py-1 w-full"
|
||||
@@ -415,5 +449,14 @@
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
{@render unparseableError()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet unparseableError()}
|
||||
{#if blockedByUnparseable}
|
||||
<div class="flex-row-reverse flex w-full text-red-600 dark:text-red-400 text-xs mt-1">
|
||||
Some input is not valid JSON. Fix it before running.
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Button } from './common'
|
||||
import { WorkerService } from '$lib/gen'
|
||||
import DateTimeInput from './DateTimeInput.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -35,10 +36,21 @@
|
||||
invisible_to_owner = $bindable(),
|
||||
overrideTag = $bindable()
|
||||
}: Props = $props();
|
||||
// As in WorkerTagPicker: the shared `workerTags` cache is the navigation workspace's, so
|
||||
// tags of another operating workspace live in a local list rather than overwriting it.
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
let usesLocal = $derived($operatingWorkspace !== $workspaceStore)
|
||||
let localWorkerTags = $state<string[] | undefined>(undefined)
|
||||
let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags)
|
||||
|
||||
loadWorkerGroups()
|
||||
|
||||
async function loadWorkerGroups() {
|
||||
if (!$workerTags) {
|
||||
if (usesLocal) {
|
||||
localWorkerTags ??= await WorkerService.getCustomTagsForWorkspace({
|
||||
workspace: $operatingWorkspace!
|
||||
})
|
||||
} else if (!$workerTags) {
|
||||
$workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: $workspaceStore! })
|
||||
}
|
||||
}
|
||||
@@ -76,7 +88,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
{#if $workerTags && $workerTags?.length > 0}
|
||||
{#if currentTags && currentTags.length > 0}
|
||||
<div class="w-full">
|
||||
<select
|
||||
placeholder="Worker group"
|
||||
@@ -96,7 +108,7 @@
|
||||
{:else}
|
||||
<option value="" disabled selected>Override Worker Group Tag</option>
|
||||
{/if}
|
||||
{#each $workerTags ?? [] as tag (tag)}
|
||||
{#each currentTags ?? [] as tag (tag)}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@@ -917,9 +917,10 @@
|
||||
|
||||
// Inside an AI session pane (which injects an aiChatManager via context) the
|
||||
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
|
||||
// fork, Exit & See details, Export — don't make sense: the session always
|
||||
// stays put and is already scoped to a fork. Diff is exposed as a standalone
|
||||
// top-bar button (rendered independently of the session pane), not here.
|
||||
// fork, Export — don't make sense: the session always stays put and is already
|
||||
// scoped to a fork. `Exit & See details` is the exception: the session hosts the
|
||||
// details page itself, so it switches the tab instead of leaving. Diff is exposed
|
||||
// as a standalone top-bar button (rendered independently of the pane), not here.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
/** Names the version on the deployed side of the diff. Without it the reader is
|
||||
@@ -1091,10 +1092,7 @@
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
...(!inSessionPane &&
|
||||
savedScript?.no_deployed !== true &&
|
||||
script.kind === 'script' &&
|
||||
!script.auto_kind
|
||||
...(savedScript?.no_deployed !== true && script.kind === 'script' && !script.auto_kind
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
|
||||
// A deployed app is the page, not a page inside Windmill's chrome, so it opens with the header out
|
||||
// of the way. Pinning brings the header back for good — one flag for every app rather than one per
|
||||
// app: someone who wants the breadcrumb over a dashboard wants it over the next one too.
|
||||
const pref = useLocalStorageValue<boolean>('app_header_pinned', false, 'boolean')
|
||||
|
||||
/** Whether the page header stays in flow on a deployed app's page. */
|
||||
export const appHeaderPinned = {
|
||||
get val(): boolean {
|
||||
return pref.val === true
|
||||
},
|
||||
set val(pinned: boolean) {
|
||||
pref.val = pinned
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@
|
||||
// UserDraft handle in that case, so the cleanup calls here must skip too
|
||||
// (otherwise we'd wipe a non-session tab's autosave at the same path). The
|
||||
// session-side equivalent is the View's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// `runtime.itemDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
|
||||
@@ -10,30 +10,63 @@
|
||||
* scoped embed token / opaque isolation.
|
||||
*/
|
||||
import { base } from '$lib/base'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { page } from '$app/state'
|
||||
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import {
|
||||
setOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
path
|
||||
path,
|
||||
onEdit,
|
||||
onLoadState,
|
||||
syncHashToUrl = true
|
||||
}: {
|
||||
workspace: string
|
||||
path: string
|
||||
/** Handle Edit in place instead of following `editHref`. An AI session shows
|
||||
* this viewer inside a preview tab, where a plain link would navigate the whole
|
||||
* page out of the session rather than flipping the tab to its editor. */
|
||||
onEdit?: () => void
|
||||
/** How the load ended, for a host that renders its own state around this viewer.
|
||||
* A 403 is deliberately neither: the app exists, this member just cannot open it. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
/** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */
|
||||
syncHashToUrl?: boolean
|
||||
} = $props()
|
||||
|
||||
// The app, its permission check and everything this viewer renders belong to `workspace`,
|
||||
// which on a session preview tab is the session's and not the one the nav points at.
|
||||
setOperatingWorkspace(() => workspace)
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
/** This workspace's membership for the viewer, which is what the app's `ctx.username` /
|
||||
* `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */
|
||||
let appUser: UserExt | undefined = $state(undefined)
|
||||
let app: any = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
let canWriteApp = $state(false)
|
||||
/** The app's own permissions, kept apart from the verdict: `loadPerms` resolves before the
|
||||
* acting user's `whoami` does in a workspace that is not the navigation one, and a verdict
|
||||
* computed there would stick at "cannot write" with nothing to recompute it. */
|
||||
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
|
||||
undefined
|
||||
)
|
||||
const canWriteApp = $derived(
|
||||
!!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current)
|
||||
)
|
||||
/** Raw vs low-code, read from the app itself rather than from the route:
|
||||
* both kinds render here and either route serves either kind (links to a raw
|
||||
* app point at /apps/get all over the app), so only the app can say which
|
||||
@@ -82,11 +115,13 @@
|
||||
// getAppByPath returns bundle_secret + runnables for raw apps, which
|
||||
// PublicApp -> RawAppPreview needs.
|
||||
async function loadApp() {
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
// Kept local and handed to PublicApp rather than written to `userStore`: this is the
|
||||
// membership in `workspace`, and a session preview tab shows a workspace the rest of
|
||||
// the page is not on — writing it globally would answer every permission check on
|
||||
// that page (the sidebar, the session bar's fork button) for the wrong workspace.
|
||||
// The routes that mount this are all under `(logged)`, whose layout has already
|
||||
// populated `userStore` for the workspace the page *is* on.
|
||||
appUser = await getUserExt(workspace)
|
||||
try {
|
||||
const loaded: any = await AppService.getAppByPath({ workspace, path })
|
||||
// Raw apps need the bundle secret to load their bundle. getAppByPath
|
||||
@@ -106,10 +141,16 @@
|
||||
app = loaded
|
||||
noPermission = false
|
||||
notExists = false
|
||||
onLoadState?.('loaded')
|
||||
} catch (e: any) {
|
||||
if (e.status == 401) refresh?.()
|
||||
else if (e.status == 403) noPermission = true
|
||||
else notExists = true
|
||||
else {
|
||||
notExists = true
|
||||
// Only a 404 is "nothing deployed here"; anything else is a failure to say so.
|
||||
if (e.status == 404) onLoadState?.('not_found')
|
||||
else sendUserToast('Could not load app: ' + (e.body ?? e.message ?? e), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,10 +160,10 @@
|
||||
async function loadPerms() {
|
||||
try {
|
||||
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
|
||||
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
|
||||
appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} }
|
||||
isRawApp = !!lite?.raw_app
|
||||
} catch (_) {
|
||||
canWriteApp = false
|
||||
appPerms = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,11 +185,13 @@
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
user={appUser}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
{syncHashToUrl}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
@@ -160,7 +203,6 @@
|
||||
<PageHeaderContent
|
||||
item={{ kind: 'app', path }}
|
||||
afterName={showEdit && !menuHidden ? editAction : undefined}
|
||||
fullBleed
|
||||
/>
|
||||
|
||||
<!-- With a band, Edit sits with the app's name rather than at the far end of the bar: on a page
|
||||
@@ -174,11 +216,14 @@
|
||||
{/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={editHref}
|
||||
href={onEdit ? undefined : editHref}
|
||||
on:click={() => onEdit?.()}
|
||||
id="app-edit-btn">Edit</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { applyDarkModeVariant } from '$lib/darkModeVariant'
|
||||
import { enterpriseLicense, userStore } from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/state'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
@@ -32,8 +32,10 @@
|
||||
onLoginSuccess,
|
||||
app,
|
||||
workspace,
|
||||
user = undefined,
|
||||
inWorkspace = false,
|
||||
hideRefreshBar = false
|
||||
hideRefreshBar = false,
|
||||
syncHashToUrl = true
|
||||
}: {
|
||||
notExists: boolean
|
||||
noPermission: boolean
|
||||
@@ -44,6 +46,11 @@
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
/** The viewer's membership in `workspace`, for the app's `ctx`. Without it `ctx` falls
|
||||
* back to `userStore`, which describes the workspace the page is navigated to — the
|
||||
* same one on every standalone viewer route, but not inside an AI session's preview
|
||||
* tab, where the app belongs to the session's workspace. */
|
||||
user?: UserExt | undefined
|
||||
/**
|
||||
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
|
||||
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
|
||||
@@ -51,11 +58,17 @@
|
||||
*/
|
||||
inWorkspace?: boolean
|
||||
hideRefreshBar?: boolean
|
||||
/** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */
|
||||
syncHashToUrl?: boolean
|
||||
} = $props()
|
||||
|
||||
// Use workspace from props or from app.workspace_id (for custom path responses)
|
||||
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
|
||||
|
||||
// `ctx.username` / `ctx.groups` are per-workspace, and they sit next to `ctx.workspace`
|
||||
// in the same object, so they have to come from the same workspace it names.
|
||||
let ctxUser = $derived(user ?? $userStore)
|
||||
|
||||
// On the public surfaces (untrusted distribution) runnable-authored html/svg needs
|
||||
// the viewer's approval before it renders, unless the app sandbox isolates it. The
|
||||
// in-workspace viewer renders it verbatim. See getAppMarkupTrust.
|
||||
@@ -156,10 +169,11 @@
|
||||
<div class={inWorkspace ? 'h-full w-full' : 'h-screen w-full'}>
|
||||
<RawAppPreview
|
||||
workspace={effectiveWorkspace}
|
||||
user={$userStore}
|
||||
user={ctxUser}
|
||||
secret={app.bundle_secret}
|
||||
path={app.path}
|
||||
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
|
||||
{syncHashToUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else if app.raw_app && !effectiveWorkspace}
|
||||
@@ -188,10 +202,10 @@
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
context={{
|
||||
email: $userStore?.email,
|
||||
name: $userStore?.name,
|
||||
groups: $userStore?.groups,
|
||||
username: $userStore?.username,
|
||||
email: ctxUser?.email,
|
||||
name: ctxUser?.name,
|
||||
groups: ctxUser?.groups,
|
||||
username: ctxUser?.username,
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { aiChatManager } from './chat/AIChatManager.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { AIBtnClasses } from './chat/AIButtonStyle'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
const { onEditInstructions, instructions, runnableType, path }: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
// Anonymous counter for this card being acted on, keyed by what it sits above. The two
|
||||
// branches share one counter: they are the same intent, and which of them is on screen
|
||||
// follows the user's session gate rather than a choice made here. `beforeOpen` is the
|
||||
@@ -39,7 +41,7 @@
|
||||
path
|
||||
? {
|
||||
target: { kind: runnableType, path } as const,
|
||||
workspaceId: $workspaceStore ?? undefined,
|
||||
workspaceId: $operatingWorkspace ?? undefined,
|
||||
beforeOpen: logAsked,
|
||||
seedPrompt:
|
||||
`Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { PanelRight } from 'lucide-svelte'
|
||||
import { Eye, Pen } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import type { IconType } from '$lib/utils'
|
||||
@@ -15,9 +15,13 @@
|
||||
/** The kind icon says what the chip opens. A card that already names its own runnable
|
||||
* in the row above has said it, and repeating it there reads as a second subject. */
|
||||
kindIcon?: boolean
|
||||
/** Which side of the item the chip opens, shown as the same pen/eye the preview
|
||||
* tabs use. Defaults to the editor: a card sits on a write-tool result, and the
|
||||
* draft it just wrote is what the reader is being offered. */
|
||||
mode?: 'edit' | 'view'
|
||||
}
|
||||
|
||||
let { card, onOpen, title, kindIcon = true }: Props = $props()
|
||||
let { card, onOpen, title, kindIcon = true, mode = 'edit' }: Props = $props()
|
||||
|
||||
const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind)
|
||||
|
||||
@@ -30,7 +34,7 @@
|
||||
}
|
||||
opening = true
|
||||
try {
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path, mode))
|
||||
} finally {
|
||||
opening = false
|
||||
}
|
||||
@@ -41,12 +45,15 @@
|
||||
variant="default"
|
||||
unifiedSize="2xs"
|
||||
disabled={opening}
|
||||
title={title ?? `Open ${kindLabel} preview: ${card.path}`}
|
||||
title={title ??
|
||||
(mode === 'view'
|
||||
? `Open the deployed ${kindLabel}: ${card.path}`
|
||||
: `Open ${kindLabel} preview: ${card.path}`)}
|
||||
onClick={open}
|
||||
startIcon={kindIcon
|
||||
? { icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }
|
||||
: undefined}
|
||||
endIcon={{ icon: PanelRight }}
|
||||
endIcon={{ icon: mode === 'view' ? Eye : Pen }}
|
||||
wrapperClasses="shrink-0"
|
||||
>
|
||||
<!-- The chip renders inside the tool row's font-mono scope; the label is UI text.
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
@@ -1160,9 +1161,34 @@ const openPreviewSchema = z.object({
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".')
|
||||
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".'),
|
||||
mode: z
|
||||
.enum(['edit', 'view'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Which side to show. Defaults to "edit", the editor for the draft you just wrote — which is what this tool is usually for. Pass "view" for the deployed page instead: its run form, triggers and past runs, for showing the user an item that already exists rather than your own changes to it. Ignored for kind="pipeline", which has only an editor.'
|
||||
)
|
||||
})
|
||||
|
||||
/** Advertised `mode` for a session that cannot write drafts: the editor is not a side
|
||||
* it can open, so it is not offered. Per-PATH write permission is unknowable here —
|
||||
* a workspace capability says nothing about `f/finance/*` — and is resolved in the
|
||||
* handler instead. Mirrors `open_page` re-narrowing its page enum per user. */
|
||||
const openPreviewViewOnlySchema = openPreviewSchema.extend({
|
||||
mode: z
|
||||
.literal('view')
|
||||
.optional()
|
||||
.describe(
|
||||
'Only "view" — the deployed page with its run form and triggers. You cannot open editors in this workspace.'
|
||||
)
|
||||
})
|
||||
|
||||
const OPEN_PREVIEW_DESCRIPTION =
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
|
||||
const OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION =
|
||||
'Show a workspace item in the side panel next to the chat, as its deployed page — the run form, triggers and past runs the user can act on. ONLY works inside an AI session. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). You cannot open item editors in this workspace, so use this to show the user something that is already deployed. Returns an error if there is no active session.'
|
||||
|
||||
const getPreviewStatusSchema = z.object({})
|
||||
|
||||
const closePageSchema = z.object({
|
||||
@@ -1470,6 +1496,10 @@ ${pipelineBullet}`
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `${when(
|
||||
!canWriteDraft,
|
||||
`
|
||||
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.`
|
||||
)}${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
|
||||
@@ -4413,14 +4443,27 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
...artifactTools,
|
||||
{
|
||||
requires: NONE,
|
||||
def: createToolDef(
|
||||
openPreviewSchema,
|
||||
'open_preview',
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
),
|
||||
def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION),
|
||||
// Withhold the editor side from a session that cannot write drafts, so the model
|
||||
// never offers the user a panel it would not get.
|
||||
schemaFor: async (helpers) => {
|
||||
const access = (helpers as GlobalToolHelpers | undefined)?.access
|
||||
return access && !access.has('write_draft')
|
||||
? createToolDef(
|
||||
openPreviewViewOnlySchema,
|
||||
'open_preview',
|
||||
OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION
|
||||
)
|
||||
: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
|
||||
},
|
||||
fn: async (ctx) => {
|
||||
const parsed = openPreviewSchema.parse(ctx.args)
|
||||
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
|
||||
return openSessionPreview(
|
||||
parsed,
|
||||
sessionIdFromCtx(ctx),
|
||||
operatingWorkspaceFromHelpers(ctx.helpers),
|
||||
(ctx.helpers as GlobalToolHelpers | undefined)?.access
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4726,6 +4769,11 @@ export type OpenPreviewHandler = (req: {
|
||||
sessionId: string | undefined
|
||||
kind: 'script' | 'flow' | 'raw_app' | 'pipeline'
|
||||
path: string
|
||||
/** Which side to open. Omitted means the editor, except for a pipeline, which has only
|
||||
* one side. `openSessionPreview` resolves it before its capability gates rather than
|
||||
* leaving the default to this handler, so an omitted mode is gated like an explicit
|
||||
* 'edit'. */
|
||||
mode?: 'edit' | 'view'
|
||||
}) => string | Promise<string>
|
||||
|
||||
let openPreviewHandler: OpenPreviewHandler | undefined
|
||||
@@ -4735,16 +4783,73 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined):
|
||||
}
|
||||
|
||||
async function openSessionPreview(
|
||||
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string },
|
||||
sessionId: string | undefined
|
||||
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string; mode?: 'edit' | 'view' },
|
||||
sessionId: string | undefined,
|
||||
workspace: string | undefined,
|
||||
access: SessionAccess | undefined
|
||||
): Promise<string> {
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
// open_preview only exists in sessions, so no sessionId check is needed here.
|
||||
// A pipeline has only an editor, and its own capability gates already cover it.
|
||||
if (args.kind === 'pipeline') {
|
||||
return await openPreviewHandler({ ...args, mode: undefined, sessionId })
|
||||
}
|
||||
// Resolved before the gates, not after: the handler reads a missing mode as the editor, so
|
||||
// gating on `args.mode === 'edit'` would wave through every call that simply omitted it —
|
||||
// which is what a model whose schema only offers 'view' does.
|
||||
const mode: 'edit' | 'view' = args.mode ?? 'edit'
|
||||
// The advertised schema already withholds 'edit' from a session that cannot write
|
||||
// drafts; re-check so a model asking outside the enum can't act outside it either.
|
||||
if (mode === 'edit' && access && !access.has('write_draft')) {
|
||||
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
|
||||
return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.`
|
||||
}
|
||||
// `write_draft` is a workspace capability and says nothing about this path, so an
|
||||
// edit that survived it is still checked against the item's own permissions.
|
||||
if (mode === 'edit' && workspace) {
|
||||
const verdict = await canEditItemPath(workspace, args.kind, args.path)
|
||||
if (verdict !== 'allowed') {
|
||||
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
|
||||
// "Couldn't check" is not "denied" — reporting the lookup failure as a denial
|
||||
// states something false about the user's permissions.
|
||||
return verdict === 'denied'
|
||||
? `${opened}\nOpened the deployed page instead: the user does not have edit rights on ${promptSafe(args.path)}.`
|
||||
: `${opened}\nOpened the deployed page: their edit rights on ${promptSafe(args.path)} couldn't be checked just now.`
|
||||
}
|
||||
}
|
||||
// For a pipeline the handler awaits the editor's tool registration, so the
|
||||
// model's next build_pipeline_node call can't race the async canvas mount.
|
||||
return await openPreviewHandler({ ...args, sessionId })
|
||||
return await openPreviewHandler({ ...args, mode, sessionId })
|
||||
}
|
||||
|
||||
/** Whether the user may edit `path` in `workspace`. `'unverified'` when their role
|
||||
* there could not be read — distinct from a denial, which `canWrite` cannot express
|
||||
* (it answers false for an unknown user). */
|
||||
async function canEditItemPath(
|
||||
workspace: string,
|
||||
kind: 'script' | 'flow' | 'raw_app',
|
||||
path: string
|
||||
): Promise<'allowed' | 'denied' | 'unverified'> {
|
||||
const role = await roleForWorkspace(workspace)
|
||||
if (role.kind === 'not_a_member') return 'denied'
|
||||
if (role.kind !== 'resolved' || !role.user) return 'unverified'
|
||||
if (role.user.operator) return 'denied'
|
||||
// Folder and ownership rules answer most calls without a request. An item's own
|
||||
// `extra_perms` can only widen them, so the item is fetched only to overturn a denial.
|
||||
if (canWrite(path, {}, role.user)) return 'allowed'
|
||||
try {
|
||||
const extraPerms =
|
||||
kind === 'script'
|
||||
? (await ScriptService.getScriptByPath({ workspace, path })).extra_perms
|
||||
: kind === 'flow'
|
||||
? (await FlowService.getFlowByPath({ workspace, path })).extra_perms
|
||||
: (await AppService.getAppByPath({ workspace, path })).extra_perms
|
||||
return canWrite(path, extraPerms ?? {}, role.user) ? 'allowed' : 'denied'
|
||||
} catch (e) {
|
||||
// Nothing deployed means no sharing to widen the folder rules with.
|
||||
return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified'
|
||||
}
|
||||
}
|
||||
|
||||
// Opens a workspace *page* (Runs, Schedules, …) as a page tab in the session's
|
||||
|
||||
@@ -16,15 +16,31 @@ export type OpenItemPreviewAction = {
|
||||
label: string
|
||||
previewKind: PreviewCardKind
|
||||
path: string
|
||||
/** Which side of the item to show. A card on a write-tool result means the
|
||||
* draft the tool just wrote, so it asks for the editor; a path the model
|
||||
* mentioned in prose means the item as it stands, so it asks for the
|
||||
* deployed page. Declared per call site because only the call site knows
|
||||
* which of the two it is. */
|
||||
mode: 'edit' | 'view'
|
||||
}
|
||||
|
||||
/** Build the action a preview card or path link dispatches from its (kind, path). */
|
||||
export function openItemPreviewAction(kind: PreviewCardKind, path: string): OpenItemPreviewAction {
|
||||
export function openItemPreviewAction(
|
||||
kind: PreviewCardKind,
|
||||
path: string,
|
||||
mode: 'edit' | 'view' = 'edit'
|
||||
): OpenItemPreviewAction {
|
||||
return {
|
||||
id: `open-item-preview:${kind}:${path}`,
|
||||
// The side is part of the identity: the same path can be offered both ways
|
||||
// in one transcript, and a shared id would collapse them into one action.
|
||||
id: `open-item-preview:${kind}:${mode}:${path}`,
|
||||
type: 'open_item_preview',
|
||||
label: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
label:
|
||||
mode === 'view'
|
||||
? `Open ${kind === 'raw_app' ? 'app' : kind}`
|
||||
: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
previewKind: kind,
|
||||
path
|
||||
path,
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,14 +1851,25 @@ describe('openItemPreviewAction', () => {
|
||||
// to previewTargetForSessionTarget.
|
||||
it('carries the kind and path through to the dispatch action', () => {
|
||||
expect(openItemPreviewAction('flow', 'f/team/etl')).toEqual({
|
||||
id: 'open-item-preview:flow:f/team/etl',
|
||||
id: 'open-item-preview:flow:edit:f/team/etl',
|
||||
type: 'open_item_preview',
|
||||
label: 'Open flow preview',
|
||||
previewKind: 'flow',
|
||||
path: 'f/team/etl'
|
||||
path: 'f/team/etl',
|
||||
mode: 'edit'
|
||||
})
|
||||
})
|
||||
|
||||
// The side is part of the id: a transcript can offer the same path both ways —
|
||||
// a write card opening its draft's editor, a prose mention opening the deployed
|
||||
// page — and one id would collapse them into a single action.
|
||||
it('distinguishes the two sides of the same item', () => {
|
||||
const edit = openItemPreviewAction('flow', 'f/team/etl')
|
||||
const view = openItemPreviewAction('flow', 'f/team/etl', 'view')
|
||||
expect(view.mode).toBe('view')
|
||||
expect(view.id).not.toBe(edit.id)
|
||||
})
|
||||
|
||||
// raw_app is the internal kind; the user-facing label says "app".
|
||||
it('labels raw_app as "app"', () => {
|
||||
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
|
||||
|
||||
@@ -271,14 +271,18 @@ export function workspaceItemAction(
|
||||
): ToolDisplayAction | undefined {
|
||||
if (!kind || !path) return undefined
|
||||
|
||||
// 'view' throughout: this registry is loaded without `include_draft_only`, so a
|
||||
// path only resolves here once something is deployed at it. A mention in prose
|
||||
// names the item as it stands, which is that deployed version — the model shows
|
||||
// its own fresh drafts through `open_preview` and the write-result card instead.
|
||||
if (kind === 'script' || kind === 'flow') {
|
||||
return openItemPreviewAction(kind, path)
|
||||
return openItemPreviewAction(kind, path, 'view')
|
||||
}
|
||||
|
||||
// Raw apps only. A legacy drag-and-drop app has no editor the panel can host, and
|
||||
// legacy items are not extended onto new surfaces — its link stays outbound.
|
||||
if (kind === 'app') {
|
||||
return rawApp ? openItemPreviewAction('raw_app', path) : undefined
|
||||
return rawApp ? openItemPreviewAction('raw_app', path, 'view') : undefined
|
||||
}
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { isWindmillTooBigObject } from '$lib/components/job_args'
|
||||
import { previewDbtRows } from './previewRows'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { appendViewToken } from '$lib/viewToken'
|
||||
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
|
||||
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
|
||||
@@ -66,6 +66,8 @@
|
||||
onResume?: () => Promise<unknown> | void
|
||||
} = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
// The graph endpoint is folder-scoped; a script outside `f/` has no folder and
|
||||
// falls back to the whole workspace, which the filter below narrows anyway.
|
||||
let folder = $derived(scriptPath.startsWith('f/') ? scriptPath.split('/')[1] : undefined)
|
||||
@@ -82,7 +84,7 @@
|
||||
// own snapshot with nothing left to fetch again and correct it.
|
||||
let graphSeq = 0
|
||||
async function load() {
|
||||
const ws = $workspaceStore
|
||||
const ws = $operatingWorkspace
|
||||
if (!ws) return
|
||||
const gen = runGen
|
||||
const seq = ++graphSeq
|
||||
@@ -128,7 +130,7 @@
|
||||
// one answer. `generation` is this page's own: a response that outlives a
|
||||
// navigation between runs would colour the next one's models.
|
||||
const runStatus = useDbtRunStatus({
|
||||
workspace: () => $workspaceStore,
|
||||
workspace: () => $operatingWorkspace,
|
||||
jobId: () => jobId,
|
||||
running: () => running,
|
||||
result: () => result,
|
||||
@@ -495,7 +497,7 @@
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
const ws = $workspaceStore
|
||||
const ws = $operatingWorkspace
|
||||
const dbt = selectedDbt
|
||||
if (!ws || !dbt) return
|
||||
// Keyed on the ARGUMENTS too, not the model alone: on the script page the
|
||||
@@ -731,18 +733,16 @@
|
||||
exactly like a project with no warehouse identity. -->
|
||||
No models stored for this version of the project.
|
||||
{#if scriptHash}
|
||||
A version's graph is written by its deploy, so this is what a deploy still in
|
||||
flight looks like — it fills in when that job lands.
|
||||
A version's graph is written by its deploy, so this is what a deploy still in flight looks
|
||||
like — it fills in when that job lands.
|
||||
{/if}
|
||||
A project that brings its own <span class="font-mono">profiles.yml</span> without naming
|
||||
a <span class="font-mono">profile.warehouse</span> also has no warehouse identity to key
|
||||
models on, and stores none.
|
||||
A project that brings its own <span class="font-mono">profiles.yml</span> without naming a
|
||||
<span class="font-mono">profile.warehouse</span> also has no warehouse identity to key models on,
|
||||
and stores none.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-hidden flex flex-col {fill ? 'h-full' : 'border rounded'}"
|
||||
>
|
||||
<div class="overflow-hidden flex flex-col {fill ? 'h-full' : 'border rounded'}">
|
||||
{#if relationDrift > 0}
|
||||
<div class="shrink-0 px-2 py-1 text-2xs text-secondary border-b bg-surface-secondary">
|
||||
{relationDrift}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Bell, BellOff, Calendar } from 'lucide-svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
@@ -54,6 +57,14 @@
|
||||
onSaved?: (newPath: string) => void
|
||||
children?: import('svelte').Snippet
|
||||
trigger_badges?: import('svelte').Snippet
|
||||
/** True for the route's own page: its header becomes the page header. A host that renders
|
||||
* this inside something else — a session's preview panel — leaves it false and gets the
|
||||
* row below. */
|
||||
ownsPageHeader?: boolean
|
||||
/** Whether the bar is wide enough to hold every button beside the summary, decided by
|
||||
* the layout from its own width. Not a viewport media query: the same page also renders
|
||||
* inside an AI session's preview panel, where the window is wide and the pane is not. */
|
||||
wide?: boolean
|
||||
/** Controls ahead of the menu, such as the way an agent's page runs it. */
|
||||
leading_actions?: import('svelte').Snippet
|
||||
}
|
||||
@@ -72,20 +83,24 @@
|
||||
onSaved,
|
||||
children,
|
||||
trigger_badges,
|
||||
ownsPageHeader = false,
|
||||
wide = true,
|
||||
leading_actions
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// These buttons share the page header's row with the breadcrumb, so what decides whether they
|
||||
// all fit is that row's width, not the window's — the sidebar and a page's side panel both take
|
||||
// from it. The trail and the summary want ~560px and the full set is ~530px wide. Unmeasured (0)
|
||||
// counts as wide: the bar measures itself on mount, and starting narrow would pop the buttons
|
||||
// out of the menu a frame later.
|
||||
// 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
|
||||
// (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
|
||||
const wide = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
|
||||
const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
@@ -96,6 +111,7 @@
|
||||
async function toggleErrorHandler() {
|
||||
if (!errorHandlerKind || !scriptOrFlowPath) return
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
$operatingWorkspace,
|
||||
errorHandlerKind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted
|
||||
@@ -104,7 +120,7 @@
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
@@ -113,7 +129,7 @@
|
||||
disabled: b.buttonProps.disabled,
|
||||
type: 'action' as const
|
||||
})),
|
||||
...(wide || !errorHandlerKind
|
||||
...(wideRow || !errorHandlerKind
|
||||
? []
|
||||
: [
|
||||
{
|
||||
@@ -129,12 +145,12 @@
|
||||
action: item.onclick,
|
||||
disabled: item.disabled,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wide
|
||||
separatorTop: i === 0 && !wideRow
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wide) return undefined
|
||||
if (wideRow) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
@@ -189,7 +205,7 @@
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wide && errorHandlerKind && scriptOrFlowPath}
|
||||
{#if wideRow && errorHandlerKind && scriptOrFlowPath}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
@@ -212,10 +228,87 @@
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in
|
||||
the breadcrumb: renaming a script or flow is done from this page, not from the trail. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: errorHandlerKind, path, summaryContent }}
|
||||
{actions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
{#if ownsPageHeader}
|
||||
<!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in
|
||||
the breadcrumb: renaming a script or flow is done from this page, not from the trail. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: errorHandlerKind, path, summaryContent }}
|
||||
{actions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Nested in a session's preview panel, which has a band of its own above it naming the
|
||||
session. A page in there draws its own row instead: registering would rename the band to
|
||||
whatever the panel happens to be showing. -->
|
||||
<div class="border-b">
|
||||
<div class="mx-auto">
|
||||
<div
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
|
||||
>
|
||||
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
|
||||
<div class="min-w-0">
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={errorHandlerKind}
|
||||
/>
|
||||
</div>
|
||||
{#if tag}
|
||||
<Badge>tag: {tag}</Badge>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
|
||||
{@const primarySchedule = triggersState.triggers.findIndex(
|
||||
(t) => t.isPrimary && !t.isDraft
|
||||
)}
|
||||
<Button
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
dispatch('seeTriggers')
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = primarySchedule
|
||||
}}
|
||||
>
|
||||
{$triggersCount?.primary_schedule?.schedule ?? ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{@render trigger_badges?.()}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
{@render leading_actions?.()}
|
||||
{#if allMenuItems.length > 0}
|
||||
{#key allMenuItems}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wideRow && errorHandlerKind && scriptOrFlowPath}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
selected: string
|
||||
forceSmallScreen?: boolean
|
||||
isChatMode?: boolean
|
||||
header?: import('svelte').Snippet
|
||||
header?: import('svelte').Snippet<[{ wide: boolean }]>
|
||||
/** `graphInline`: whether the form should carry the flow graph under it. It does in the
|
||||
* split layout; the tabbed layout gives the graph a tab of its own. */
|
||||
form?: import('svelte').Snippet<[{ graphInline: boolean }]>
|
||||
@@ -67,10 +67,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- `h-full`, not `h-screen`: this page is also rendered inside an AI session's preview
|
||||
panel, which is shorter than the viewport. Claiming the viewport's height there
|
||||
overflows the pane, and the ancestors between are `overflow-visible`, so the bottom of
|
||||
the run form is unreachable rather than merely clipped. -->
|
||||
<main class="h-full w-full" bind:clientWidth>
|
||||
{#if useDesktopLayout}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
{@render header?.({ wide: useDesktopLayout })}
|
||||
<div class="grow min-h-0 w-full">
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={50}>
|
||||
@@ -101,7 +105,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
{@render header?.({ wide: useDesktopLayout })}
|
||||
<div class="grow min-h-0 w-full flex flex-col">
|
||||
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
|
||||
classic scrollbars would spend a track under them, opening a band between the tabs
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
interface Props {
|
||||
kind: 'script' | 'flow'
|
||||
@@ -22,7 +25,12 @@
|
||||
}: Props = $props()
|
||||
|
||||
async function toggleErrorHandler(): Promise<void> {
|
||||
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
$operatingWorkspace,
|
||||
kind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted
|
||||
)
|
||||
if (next !== undefined) errorHandlerMuted = next
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type TriggersCount,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
defaultIfEmptyString,
|
||||
emptyString,
|
||||
urlParamsToObject,
|
||||
extractTagFromSharableHash,
|
||||
interpolateTag,
|
||||
isDynamicTag,
|
||||
isTagTemplate
|
||||
} from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
|
||||
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
|
||||
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
import {
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Trash,
|
||||
ChevronUpSquare,
|
||||
Shield,
|
||||
Loader2,
|
||||
GitFork,
|
||||
Play,
|
||||
History,
|
||||
Pen,
|
||||
Eye,
|
||||
HistoryIcon,
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { setContext } from 'svelte'
|
||||
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import { interceptNav } from '$lib/components/details/interceptNav'
|
||||
import {
|
||||
setOperatingWorkspace,
|
||||
useOperatingUser,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkDescription,
|
||||
editInForkLabel,
|
||||
onEditInForkClick,
|
||||
openEditInFork
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
let {
|
||||
path: routePath,
|
||||
workspace,
|
||||
searchParams,
|
||||
locationHash = '',
|
||||
onNavigate = goto,
|
||||
active = true,
|
||||
embedded = false,
|
||||
onLoadState
|
||||
}: {
|
||||
/** The `[...path]` route segment: the flow's path. */
|
||||
path: string
|
||||
workspace: string | undefined
|
||||
/** Query of the URL this detail was opened at (`version`). */
|
||||
searchParams?: URLSearchParams
|
||||
/** `#`-fragment of that URL, carrying prefilled run args from `Run again`. */
|
||||
locationHash?: string
|
||||
onNavigate?: (url: string) => void | Promise<void>
|
||||
/** Gates the window-level Ctrl+Enter handler, so a hidden instance cannot steal it. */
|
||||
active?: boolean
|
||||
/** Rendered inside a page that is not the flow's own (an AI session preview tab), whose
|
||||
* URL this must leave alone and which a cross-workspace link must not navigate away. */
|
||||
embedded?: boolean
|
||||
/** How the load ended, for a host that renders its own state around this page. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
} = $props()
|
||||
|
||||
// Every workspace-scoped call below and in the drawers this page opens targets
|
||||
// `workspace`, not the nav store: a session views a flow in its own workspace.
|
||||
setOperatingWorkspace(() => workspace)
|
||||
|
||||
// Roles are per-workspace, so every gate below asks about `workspace` rather than about
|
||||
// the one the browser is navigated to. Unresolved reads as unknown, which `canWrite`
|
||||
// refuses — the safe answer while a fork's `whoami` is still in flight.
|
||||
const operatingUser = useOperatingUser()
|
||||
// A ⌘-click opens these in a new tab, which only the query names the workspace for.
|
||||
const operatingHref = useOperatingWorkspaceHref()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
// Derived, not assigned during the load: in a fork the acting user is looked up
|
||||
// asynchronously, and a value read after `getFlowByPath` resolves can land before that
|
||||
// lookup does. Assigned once, that leaves the page permanently read-only — `canWrite`
|
||||
// refuses an unknown user — with nothing to recompute it.
|
||||
const can_write = $derived(!!flow && canWrite(flow.path, flow.extra_perms!, actingUser))
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
|
||||
let scheduledForStr: string | undefined = $state(undefined)
|
||||
let invisible_to_owner: boolean | undefined = $state(undefined)
|
||||
let overrideTag: string | undefined = $state(undefined)
|
||||
let overrideTagNote: string | undefined = $state(undefined)
|
||||
// Tag carried over from 'Run again', pending the dynamic-tag check in loadFlow
|
||||
let carriedTag: string | undefined = undefined
|
||||
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
|
||||
let jsonView = $state(false)
|
||||
let deploymentInProgress = $state(false)
|
||||
let deploymentJobId: string | undefined = $state(undefined)
|
||||
|
||||
let intervalId: number | undefined = undefined
|
||||
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
{ type: 'cli', path: '', isDraft: false }
|
||||
])
|
||||
)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState
|
||||
})
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flow?.value.modules ?? [] })
|
||||
)
|
||||
|
||||
// `${path}|${version}` so navigating between pinned and latest re-runs loadFlow.
|
||||
let previousLoadKey: string | undefined = $state(undefined)
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function deleteFlow(): Promise<void> {
|
||||
await FlowService.deleteFlowByPath({ workspace: workspace!, path })
|
||||
sendUserToast('Flow deleted')
|
||||
// Embedded, the host owns the tab: reloading lets it show that nothing is deployed
|
||||
// here, rather than opening Home beside a tab still showing the deleted flow.
|
||||
if (embedded) {
|
||||
loadFlow().then(
|
||||
() => onLoadState?.('loaded'),
|
||||
(e) => e?.status === 404 && onLoadState?.('not_found')
|
||||
)
|
||||
} else onNavigate('/')
|
||||
}
|
||||
|
||||
async function loadTriggersCount() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: workspace!,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
await triggersState.fetchTriggers(triggersCount, workspace, path, true, undefined, actingUser)
|
||||
}
|
||||
|
||||
let pinnedVersion: number | undefined = $state(undefined)
|
||||
|
||||
async function loadFlow(): Promise<void> {
|
||||
const versionParam = searchParams?.get('version')
|
||||
const versionId = versionParam ? Number(versionParam) : NaN
|
||||
if (Number.isFinite(versionId)) {
|
||||
const versioned = await FlowService.getFlowVersion({
|
||||
workspace: workspace!,
|
||||
version: versionId
|
||||
})
|
||||
if (versioned.path !== path) {
|
||||
sendUserToast(`Flow version ${versionId} belongs to ${versioned.path}, not ${path}.`, true)
|
||||
onNavigate(`/flows/get/${versioned.path}?workspace=${workspace}&version=${versionId}`)
|
||||
return
|
||||
}
|
||||
flow = versioned
|
||||
pinnedVersion = versionId
|
||||
} else {
|
||||
flow = await FlowService.getFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
withStarredInfo: true
|
||||
})
|
||||
pinnedVersion = undefined
|
||||
}
|
||||
// A carried non-template value equal to the resolution of the flow's own dynamic
|
||||
// tag for these args is not an override but the previous run's pinned resolution:
|
||||
// drop it so the backend re-resolves from the (possibly edited) args. A differing
|
||||
// value is a genuine user override and a template re-resolves at push, so both stay.
|
||||
if (
|
||||
carriedTag &&
|
||||
isDynamicTag(flow.tag) &&
|
||||
!isTagTemplate(carriedTag) &&
|
||||
interpolateTag(flow.tag ?? '', workspace!, args) === carriedTag
|
||||
) {
|
||||
if (overrideTag === carriedTag) {
|
||||
overrideTag = undefined
|
||||
overrideTagNote = `tag ${flow.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
|
||||
}
|
||||
carriedTag = undefined
|
||||
}
|
||||
if (!flow.path.startsWith(`u/${actingUser?.username}`) && flow.path.split('/').length > 2) {
|
||||
invisible_to_owner = flow.visible_to_runner_only
|
||||
}
|
||||
intervalId && clearInterval(intervalId)
|
||||
deploymentInProgress = flow.lock_error_logs == ''
|
||||
if (deploymentInProgress) {
|
||||
intervalId = setInterval(syncer, 500)
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let loading = $state(false)
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (flow) {
|
||||
const status = await FlowService.getFlowDeploymentStatus({
|
||||
workspace: workspace!,
|
||||
path: flow.path
|
||||
})
|
||||
if (status.lock_error_logs == undefined || status.lock_error_logs != '') {
|
||||
deploymentInProgress = false
|
||||
deploymentJobId = undefined
|
||||
flow.lock_error_logs = status.lock_error_logs
|
||||
clearInterval(intervalId)
|
||||
} else if (status.job_id) {
|
||||
deploymentJobId = status.job_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlow(
|
||||
scheduledForStr: string | undefined,
|
||||
args: Record<string, any>,
|
||||
invisibleToOwner: boolean | undefined,
|
||||
overrideTag: string | undefined
|
||||
) {
|
||||
loading = true
|
||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||
try {
|
||||
let run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
invisibleToOwner,
|
||||
requestBody: args,
|
||||
scheduledFor,
|
||||
tag: overrideTag,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
await onNavigate('/run/' + run + '?workspace=' + workspace)
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlowForChat(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
memoryId: conversationId,
|
||||
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
skipPreprocessor: true
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
let args: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
// Read once on purpose: these args seed the form, so tracking the fragment would
|
||||
// overwrite what the user has typed whenever it changes.
|
||||
let hash = untrack(() => locationHash)
|
||||
if (hash.length > 1) {
|
||||
try {
|
||||
let searchParams = new URLSearchParams(hash.slice(1))
|
||||
carriedTag = extractTagFromSharableHash(searchParams)
|
||||
overrideTag = carriedTag
|
||||
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
|
||||
k,
|
||||
JSON.parse(v)
|
||||
])
|
||||
args = Object.fromEntries(params)
|
||||
} catch (e) {
|
||||
console.error('Was not able to transform hash as args', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Operators with the builder right author flows out of deployed runnables; every other
|
||||
// operator is read-only here.
|
||||
let canAuthorFlow = $derived(!actingUser?.operator || $operatorBuilderFlows)
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let runForm: RunForm | undefined = $state()
|
||||
|
||||
// The dev workspace's editor is not one the session panel can host, so from a preview tab
|
||||
// it opens in a new browser tab, as the session editors' own entry does.
|
||||
function editInFork(e: Event | undefined, itemPath: string) {
|
||||
if (!embedded) {
|
||||
return onEditInForkClick(e, 'flow', itemPath, { hasHref: true, prodWorkspace: workspace })
|
||||
}
|
||||
const m = e as MouseEvent | undefined
|
||||
if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return
|
||||
e?.preventDefault()
|
||||
return openEditInFork('flow', itemPath, workspace)
|
||||
}
|
||||
|
||||
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
|
||||
const buttons: any = []
|
||||
|
||||
if (flow && canAuthorFlow) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/flows/add?template=${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
flow &&
|
||||
!actingUser?.operator &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(workspace, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('flow', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, flow.path),
|
||||
unifiedSize: 'md',
|
||||
variant: !showEditButtons ? 'default' : 'subtle',
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: `Runs`,
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/runs/${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/runs/${flow.path}`),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: Play
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: History
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || !canAuthorFlow || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
// The builder right covers flows only; building an app is still refused to operators.
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
href: operatingHref(`${base}/flows/edit/${path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/edit/${path}`),
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
return buttons
|
||||
}
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: workspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
function getMenuItems(
|
||||
flow: Flow | undefined,
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
) {
|
||||
if (!flow || !canAuthorFlow) return []
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Permissions',
|
||||
onclick: () => shareModal?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: Shield,
|
||||
disabled: !can_write
|
||||
})
|
||||
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
|
||||
Icon: FolderOpen
|
||||
})
|
||||
}
|
||||
|
||||
// The builder right opens this menu to operators; audit logs stay behind their own setting.
|
||||
if (
|
||||
!actingUser?.operator ||
|
||||
$userWorkspaces.find((w) => w.id === workspace)?.operator_settings?.audit_logs
|
||||
) {
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
Icon: Eye,
|
||||
onclick: () => {
|
||||
onNavigate(`/audit_logs?resource=${flow?.path}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !actingUser?.operator) {
|
||||
menuItems.push({
|
||||
label: 'Deploy to staging/prod',
|
||||
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: ChevronUpSquare
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
Icon: HistoryIcon
|
||||
})
|
||||
menuItems.push({
|
||||
label: flow.archived ? 'Unarchive' : 'Archive',
|
||||
onclick: () => flow?.path && archiveFlow(),
|
||||
Icon: Archive,
|
||||
color: 'red'
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
onclick: () => flow?.path && deleteFlow(),
|
||||
Icon: Trash,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
return menuItems
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
intervalId && clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
// Several instances can be mounted at once (a session keeps hidden tabs
|
||||
// mounted), and each one listens on the window: only the visible one runs.
|
||||
// `defaultPrevented` keeps anything nearer that already claimed the key — the chat
|
||||
// composer's send, a drawer's Escape — from also reaching the form.
|
||||
if (!active || event.defaultPrevented) return
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm?.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let detailLayout: DetailPageLayout | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(routePath ?? '')
|
||||
|
||||
let topSectionHeight = $state(0)
|
||||
let paneHeight = $state(0)
|
||||
let flowGraphHeight = $state(0)
|
||||
|
||||
let graphMinHeight = $derived.by(() => {
|
||||
if (!topSectionHeight || !paneHeight) return 400
|
||||
const availableHeight = paneHeight - topSectionHeight - 1 // Account for the separator between the top section and the graph
|
||||
return Math.max(400, availableHeight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
|
||||
if (cliTrigger) {
|
||||
cliTrigger.extra = {
|
||||
cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
// `$userStore`, not the acting user: this asks whether the session is authenticated at
|
||||
// all, which is global. Waiting on the acting user instead would hold the load for a
|
||||
// fork's `whoami`, and never load it at all if that lookup failed.
|
||||
if (workspace && $userStore && routePath) {
|
||||
const versionParam = searchParams?.get('version') ?? ''
|
||||
const loadKey = `${workspace}|${path}|${versionParam}`
|
||||
if (previousLoadKey !== loadKey) {
|
||||
previousLoadKey = loadKey
|
||||
untrack(() => {
|
||||
// A path with no deployed flow rejects here, which is the normal state of a
|
||||
// draft the user has not deployed — reported so a host can say so in place
|
||||
// rather than leaving the page blank on an unhandled rejection.
|
||||
loadFlow().then(
|
||||
() => onLoadState?.('loaded'),
|
||||
(e) => {
|
||||
if (onLoadState && e?.status === 404) onLoadState('not_found')
|
||||
else sendUserToast('Could not load flow: ' + (e?.body ?? e?.message ?? e), true)
|
||||
}
|
||||
)
|
||||
loadTriggersCount()
|
||||
loadTriggers()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await onNavigate('/flows/get/' + e.detail + `?workspace=${workspace}`)
|
||||
loadFlow()
|
||||
}}
|
||||
/>
|
||||
{#if flow}
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:this={detailLayout}
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={actingUser?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
isChatMode={chatInputEnabled}
|
||||
flow_json={{
|
||||
value: flow?.value,
|
||||
summary: flow?.summary,
|
||||
description: flow?.description,
|
||||
schema: flow?.schema
|
||||
}}
|
||||
>
|
||||
{#snippet header({ wide }: { wide: boolean })}
|
||||
<DetailPageHeader
|
||||
{wide}
|
||||
ownsPageHeader={!embedded}
|
||||
on:seeTriggers={() => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
{mainButtons}
|
||||
menuItems={getMenuItems(flow, deployUiSettings)}
|
||||
bind:errorHandlerMuted={
|
||||
() => flow?.ws_error_handler_muted ?? false,
|
||||
(v) => {
|
||||
if (flow !== undefined) flow.ws_error_handler_muted = v
|
||||
}
|
||||
}
|
||||
scriptOrFlowPath={flow?.path ?? ''}
|
||||
errorHandlerKind="flow"
|
||||
tag={flow?.tag ?? ''}
|
||||
labels={flow?.labels}
|
||||
inheritedLabels={flow?.inherited_labels}
|
||||
summary={flow?.summary}
|
||||
path={flow?.path}
|
||||
onSaved={can_write
|
||||
? async (newPath) => {
|
||||
if (newPath !== flow?.path) {
|
||||
await onNavigate(`/flows/get/${newPath}?workspace=${workspace}`)
|
||||
} else {
|
||||
loadFlow()
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
showDraft={false}
|
||||
{path}
|
||||
newItem={false}
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
detailLayout?.showTriggers()
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
kind="flow"
|
||||
/>
|
||||
{#if flow?.value?.priority != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="blue" variant="outlined" size="xs">
|
||||
{`Priority: ${flow?.value?.priority}`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="gray" variant="outlined" size="xs">
|
||||
{`Concurrency limit: ${flow?.value?.concurrent_limit} runs every ${flow?.value?.concurrency_time_window_s}s`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form({ graphInline }: { graphInline: boolean })}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
{#if flow}
|
||||
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
|
||||
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
<!-- The chat reaches the edges of the pane, so the notices above it carry their
|
||||
own padding. `contents` leaves the form layout exactly as it was. -->
|
||||
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
|
||||
comment anchor behind, so an empty wrapper is not `:empty` and its own
|
||||
padding would show as a gap above a chat with nothing to announce. -->
|
||||
<div
|
||||
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a
|
||||
class="underline"
|
||||
href="/flows/get/{path}?workspace={workspace}"
|
||||
onclick={interceptNav(onNavigate, `/flows/get/${path}`)}
|
||||
>
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<!-- In chat mode the description belongs to the chat, which shows it under the
|
||||
empty transcript. -->
|
||||
{#if !chatInputEnabled && !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={workspace}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
<FlowChat
|
||||
onRunFlow={runFlowForChat}
|
||||
{deploymentInProgress}
|
||||
path={flow?.path ?? ''}
|
||||
description={flow?.description}
|
||||
inputSchema={flow?.schema}
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
<!-- Normal Mode: Form Layout -->
|
||||
<div class="flex flex-col align-left">
|
||||
{#if hasSchema || inputSelected}
|
||||
<div
|
||||
class="flex flex-row justify-between min-h-12"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<InputSelectedBadge
|
||||
onReject={() => {
|
||||
savedInputsV2?.resetSelected()
|
||||
}}
|
||||
{inputSelected}
|
||||
/>
|
||||
|
||||
{#if hasSchema}
|
||||
<Toggle
|
||||
bind:checked={jsonView}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: 'Fill args from JSON'
|
||||
}}
|
||||
lightMode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.schema?.prompt_for_ai !== undefined}
|
||||
<AIFormAssistant
|
||||
instructions={flow.schema?.prompt_for_ai as string}
|
||||
onEditInstructions={() => {
|
||||
onNavigate(`/flows/edit/${flow?.path}`)
|
||||
}}
|
||||
runnableType="flow"
|
||||
path={flow?.path}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
{overrideTagNote}
|
||||
syncArgsToUrl={!embedded}
|
||||
viewKeybinding
|
||||
{loading}
|
||||
autofocus
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
bind:args
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex flex-col gap-1 w-full items-end">
|
||||
<span class="text-2xs text-secondary">
|
||||
Edited <TimeAgo date={flow.edited_at ?? ''} noSeconds /> by {flow.edited_by}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if graphInline}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
minHeight={graphMinHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet save_inputs()}
|
||||
<SavedInputsV2
|
||||
bind:this={savedInputsV2}
|
||||
schema={flow?.schema}
|
||||
{jsonView}
|
||||
flowPath={flow?.path}
|
||||
{isValid}
|
||||
args={args ?? {}}
|
||||
bind:inputSelected
|
||||
on:selected_args={(e) => {
|
||||
const nargs = JSON.parse(JSON.stringify(e.detail))
|
||||
args = nargs
|
||||
if (jsonView) {
|
||||
runForm?.syncJsonEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step({ onBack }: { onBack?: () => void })}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {onBack} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{#if flow}
|
||||
<TriggersEditor
|
||||
{args}
|
||||
runnableVersion={flow.version_id?.toString()}
|
||||
initialPath={flow.path}
|
||||
currentPath={flow.path}
|
||||
noEditor={true}
|
||||
newItem={false}
|
||||
isFlow={true}
|
||||
schema={flow.schema}
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_graph()}
|
||||
{#if flow}
|
||||
<div class="h-full overflow-auto" bind:clientHeight={flowGraphHeight}>
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageLayout>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flow?.value.modules ?? []}
|
||||
enableDbExplore
|
||||
enablePathScriptAndFlowAssets
|
||||
/>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { FlowService, ScriptService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
/** Flips the workspace error handler for one script or flow and reports the outcome in a
|
||||
* toast. Returns the new muted state, or undefined when nothing changed. */
|
||||
* toast. Returns the new muted state, or undefined when nothing changed. The workspace is the
|
||||
* caller's to supply — a detail page embedded in an AI session acts on that session's
|
||||
* workspace, which is not the one the navigation store names. */
|
||||
export async function toggleWorkspaceErrorHandler(
|
||||
workspace: string | undefined,
|
||||
kind: 'script' | 'flow',
|
||||
path: string,
|
||||
muted: boolean | undefined
|
||||
): Promise<boolean | undefined> {
|
||||
const workspace = get(workspaceStore)
|
||||
if (workspace === undefined) return undefined
|
||||
const next = !muted
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Route a link's plain left-click through the detail page's `onNavigate` instead of
|
||||
* letting the browser follow it. The detail pages keep real `href`s so a reader can
|
||||
* still middle-click or ⌘-click one into a new tab — those clicks are left alone —
|
||||
* but a plain click has to reach `onNavigate`, which in an AI session preview moves
|
||||
* within the panel rather than taking the browser out of the session.
|
||||
*/
|
||||
export function interceptNav(
|
||||
onNavigate: (url: string) => void | Promise<void>,
|
||||
url: string
|
||||
): (e?: Event) => void {
|
||||
return (e?: Event) => {
|
||||
const m = e as MouseEvent | undefined
|
||||
if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return
|
||||
e?.preventDefault()
|
||||
void onNavigate(url)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@
|
||||
variant="accent"
|
||||
on:click={() =>
|
||||
window.open(
|
||||
`/flows/add?template_id=${selectedVersion?.id}&template=${path}`,
|
||||
`/flows/add?template_id=${selectedVersion?.id}&template=${path}&workspace=${$operatingWorkspace}`,
|
||||
'_blank'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
import AgentEvalsModal from './AgentEvalsModal.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -87,6 +89,9 @@
|
||||
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
|
||||
let versionDrawer: Drawer | undefined = $state(undefined)
|
||||
let settingsDrawer: Drawer | undefined = $state(undefined)
|
||||
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
|
||||
* reflow under the pointer as the user types. */
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let saving = $state(false)
|
||||
|
||||
@@ -310,7 +315,7 @@
|
||||
breadcrumb, the level it is on reads after that name, and its controls are the band's
|
||||
actions. -->
|
||||
<PageHeaderContent
|
||||
item={{ path: shownPath, summaryContent: agentBadges }}
|
||||
item={{ path: pathSnapshot ?? shownPath, summaryContent: agentSummary }}
|
||||
afterName={agentHint}
|
||||
actions={settings}
|
||||
/>
|
||||
@@ -322,11 +327,46 @@
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#snippet agentBadges()}
|
||||
<!-- The version rides with the agent's name, as the linked-agent card in the step panel has
|
||||
it. The trail's levels are not here: this layout opens settings in a drawer and evals
|
||||
over the page, so it never stands on one. -->
|
||||
{@render titleBadge()}
|
||||
{#snippet agentSummary()}
|
||||
<!-- What the agent is, beside what it is called, and the pen that renames both — the same
|
||||
shape the script, flow and app editors carry. The version rides along, as the
|
||||
linked-agent card in the step panel has it. The trail's levels are not here: this layout
|
||||
opens settings in a drawer and evals over the page, so it never stands on one. -->
|
||||
<div class="group flex items-center gap-1 min-w-0">
|
||||
{#if draft?.state}
|
||||
<span
|
||||
class="min-w-0 truncate text-xs {emptyString(draft.state.description)
|
||||
? 'text-tertiary italic font-normal'
|
||||
: 'font-medium text-emphasis'}"
|
||||
title={draft.state.description}
|
||||
>{emptyString(draft.state.description)
|
||||
? 'Add a summary...'
|
||||
: draft.state.description}</span
|
||||
>
|
||||
<PathEditPopover
|
||||
bind:summary={
|
||||
() => draft.state?.description ?? '',
|
||||
(v) => {
|
||||
if (draft.state) draft.state.description = v
|
||||
}
|
||||
}
|
||||
bind:path={
|
||||
() => draft.state?.path ?? '',
|
||||
(v) => {
|
||||
if (draft.state) draft.state.path = v
|
||||
}
|
||||
}
|
||||
bind:snapshotPath={pathSnapshot}
|
||||
savedPath={draft?.noDeployed ? undefined : target?.path}
|
||||
kind="resource"
|
||||
workspaceId={ws}
|
||||
pathEditable={!readOnly}
|
||||
summaryEditable={!readOnly}
|
||||
penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'}
|
||||
/>
|
||||
{/if}
|
||||
{@render titleBadge()}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet agentHint()}
|
||||
{#if description}
|
||||
|
||||
@@ -137,6 +137,7 @@ repeatedly — check the brand's own page.
|
||||
| `FunkwhaleIcon` | `funkwhale` | mixed | #009FE3 | #009FE3 | 10.69 | 5.71 | www.funkwhale.audio/logos (theme/images/icon.svg) |
|
||||
| `GSheetsIcon` | `gsheets` | fixed | #009954 | #009954 | 3.57 | 12.47 | Google product logo sheets_2026q3 (gstatic productlogos, used on workspace.google.com/products/sheets) |
|
||||
| `GcalIcon` | `gcal` | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | Google's own Calendar 2026 product logo, https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg (paths verbatim) |
|
||||
| `GchatIcon` | `gchat` | fixed | #0EBC5F | #0EBC5F | 2.80 | 4.98 | Google's own Chat 2026 product icon, https://www.gstatic.com/images/branding/productlogos/chat_2026/v2/web/192px.svg (paths verbatim, ids namespaced) |
|
||||
| `GdocsIcon` | `gdocs` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | Google's own Docs product icon (gstatic.com/images/branding/productlogos/docs_2026/v2/web/192px.svg, served on workspace.google.com/products/docs) |
|
||||
| `GdriveIcon` | `gdrive` | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg, Google's own product-logo CDN; paths and gradient stops are verbatim |
|
||||
| `GhostCmsIcon` | `ghostcms` | pair | #15171A | #FFFFFF | 17.38 | 12.47 | docs.ghost.org |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
height?: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
let { height = '24px', width = '24px' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- Google's own Chat product icon, gstatic.com/images/branding/productlogos/chat_2026/v2/web/192px.svg.
|
||||
Google forbids recolouring its logos, so this ships unmodified apart from namespacing the
|
||||
gradient/mask ids, which are document-global. -->
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 192 192"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Google Chat"
|
||||
>
|
||||
<rect width="160" height="96" x="16" y="28" fill="#00AF57" rx="48" />
|
||||
<path
|
||||
fill="#0EBC5F"
|
||||
d="M133 48c28.167 0 51 22.834 51 51 0 28.167-22.833 51-51 51H96.624l-34.857 23.064c-3.86 2.544-5.789 3.816-7.372 3.92a6 6 0 0 1-5.612-3.022C48 172.583 48 170.271 48 165.649V148.81C25.121 143.78 8 123.39 8 99c0-28.166 22.834-51 51-51h74z"
|
||||
/>
|
||||
<mask
|
||||
id="gchat-mask-a"
|
||||
width="176"
|
||||
height="129"
|
||||
x="8"
|
||||
y="48"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style="mask-type:alpha"
|
||||
>
|
||||
<path
|
||||
fill="#0EBC5F"
|
||||
d="M133 48c28.167 0 51 22.834 51 51 0 28.167-22.833 51-51 51H96.722l-39.428 25.896c-3.99 2.62-9.294-.242-9.294-5.015V148.81C25.121 143.78 8 123.39 8 99c0-28.166 22.834-51 51-51h74z"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#gchat-mask-a)">
|
||||
<rect width="160" height="96" x="16" y="28" fill="#0EBC5F" rx="48" />
|
||||
<rect width="160" height="96" x="16" y="28" fill="url(#gchat-paint-b)" rx="48" />
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-width="12"
|
||||
d="M62 94s8.84 18 34 18 34-17.182 34-17.182"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gchat-paint-b"
|
||||
x1="96"
|
||||
x2="96"
|
||||
y1="28"
|
||||
y2="124"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset=".09" stop-color="#94D4FF" />
|
||||
<stop offset=".28" stop-color="#78C9FF" />
|
||||
<stop offset=".88" stop-color="#01AE58" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -33,6 +33,7 @@ import StripeIcon from './StripeIcon.svelte'
|
||||
import TelegramIcon from './TelegramIcon.svelte'
|
||||
import FunkwhaleIcon from './FunkwhaleIcon.svelte'
|
||||
import GdocsIcon from './GdocsIcon.svelte'
|
||||
import GchatIcon from './GchatIcon.svelte'
|
||||
import NextcloudIcon from './NextcloudIcon.svelte'
|
||||
import NetsuiteIcon from './NetsuiteIcon.svelte'
|
||||
import FaunadbIcon from './FaunadbIcon.svelte'
|
||||
@@ -386,6 +387,7 @@ export const APP_TO_ICON_COMPONENT = {
|
||||
helper: WindmillIcon,
|
||||
windmillhub: WindmillIcon,
|
||||
gdocs: GdocsIcon,
|
||||
gchat: GchatIcon,
|
||||
ocs: NextcloudIcon,
|
||||
faunadb: FaunadbIcon,
|
||||
clickhouse: ClickhouseIcon,
|
||||
@@ -715,6 +717,7 @@ export {
|
||||
DatadogIcon,
|
||||
FunkwhaleIcon,
|
||||
GdocsIcon,
|
||||
GchatIcon,
|
||||
FaunadbIcon,
|
||||
ClickhouseIcon,
|
||||
OpenaiIcon,
|
||||
|
||||
@@ -45,9 +45,6 @@ export type PageHeaderContent = {
|
||||
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
|
||||
* when it really is a fork. */
|
||||
actingWorkspaceId?: string
|
||||
/** The page is the whole viewport — a deployed app. The band leaves the flow and waits behind
|
||||
* a handle in the corner, until the viewer pins it. */
|
||||
fullBleed?: boolean
|
||||
/** Width in px, at the right of the viewport, that this page owns from the top down — a
|
||||
* session's side panel. The band stops there instead of running over it, and the band floats
|
||||
* above the page rather than pushing it down, so that column starts at the top. */
|
||||
@@ -101,7 +98,6 @@ export const pageHeader = {
|
||||
if (c.contexts !== undefined) merged.contexts = c.contexts
|
||||
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
|
||||
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
|
||||
}
|
||||
return merged
|
||||
|
||||
@@ -16,6 +16,18 @@ describe('pendingEditorFlush', () => {
|
||||
expect(anyEditorUnparseable()).toBe(false)
|
||||
})
|
||||
|
||||
it("scopes the check to one form, so another form's broken editor does not block it", () => {
|
||||
const insideA = {} as Element
|
||||
const formA = { contains: (el: Element) => el === insideA } as Element
|
||||
const formB = { contains: () => false } as unknown as Element
|
||||
const editor = {}
|
||||
setEditorUnparseable(editor, true, insideA)
|
||||
expect(anyEditorUnparseable(formA)).toBe(true)
|
||||
expect(anyEditorUnparseable(formB)).toBe(false)
|
||||
expect(anyEditorUnparseable()).toBe(true)
|
||||
setEditorUnparseable(editor, false)
|
||||
})
|
||||
|
||||
it('flushes registered editors, and stops once they unmount', () => {
|
||||
let flushed = 0
|
||||
const deregister = registerPendingEditor({ flushPendingChanges: () => flushed++ })
|
||||
|
||||
@@ -20,17 +20,22 @@ export function flushAllPendingEditorChanges(): void {
|
||||
// Editors whose current text does not parse. Their value never reaches the bound field, so
|
||||
// a caller persisting "what is on screen" would save the last value that did parse and
|
||||
// leave without it. Registered by the editors that parse, not by the ones that only hold text.
|
||||
const unparseable = new Set<object>()
|
||||
const unparseable = new Map<object, Element | undefined>()
|
||||
|
||||
/** Mark or clear this editor as holding text that does not parse. */
|
||||
export function setEditorUnparseable(key: object, invalid: boolean): void {
|
||||
if (invalid) unparseable.add(key)
|
||||
/** Mark or clear this editor as holding text that does not parse. `el` is where the editor
|
||||
* sits, for a caller that checks only its own part of the page. */
|
||||
export function setEditorUnparseable(key: object, invalid: boolean, el?: Element): void {
|
||||
if (invalid) unparseable.set(key, el)
|
||||
else unparseable.delete(key)
|
||||
}
|
||||
|
||||
/** Whether any editor on screen holds text that cannot be persisted as written. Registry-
|
||||
* wide rather than per-item: the editors that parse are nested arbitrarily deep and none
|
||||
* of them knows which draft it belongs to. */
|
||||
export function anyEditorUnparseable(): boolean {
|
||||
return unparseable.size > 0
|
||||
/** Whether any editor holds text that cannot be persisted as written. Registry-wide rather
|
||||
* than per-item: the editors that parse are nested arbitrarily deep and none of them knows
|
||||
* which draft it belongs to. `within` narrows it to the editors inside one element, for a
|
||||
* form that shares the page with others: a session keeps hidden preview tabs mounted, and
|
||||
* one tab's broken JSON must not stop another tab's form from running. */
|
||||
export function anyEditorUnparseable(within?: Element): boolean {
|
||||
if (!within) return unparseable.size > 0
|
||||
for (const el of unparseable.values()) if (el && within.contains(el)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
|
||||
// own removal (it would target the wrong, navigation-workspace key). The
|
||||
// session-side equivalent is RawAppEditorView's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// `runtime.itemDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
|
||||
import { stripRawAppDiffNoise } from './utils'
|
||||
|
||||
@@ -21,9 +21,15 @@
|
||||
* (publish flow) needs it to read the app's DOM, which is only possible on
|
||||
* the unsandboxed path. */
|
||||
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
|
||||
/** Mirror the app's own route into the page URL's hash, and start from it. Off where
|
||||
* the app is embedded in a page that is not its own — an AI session preview tab —
|
||||
* since there the hash belongs to that page and to whichever app was opened first. */
|
||||
syncHashToUrl?: boolean
|
||||
}
|
||||
|
||||
let { workspace, user, secret, path, runnables, oniframe }: Props = $props()
|
||||
let { workspace, user, secret, path, runnables, oniframe, syncHashToUrl = true }: Props =
|
||||
$props()
|
||||
const pageHash = () => (syncHashToUrl ? window.location.hash : '')
|
||||
|
||||
$effect(() => {
|
||||
const el = unsandboxed ? iframe : undefined
|
||||
@@ -86,7 +92,7 @@
|
||||
secret,
|
||||
{ ctx: u, workspace },
|
||||
window.location.origin,
|
||||
window.location.hash || ''
|
||||
pageHash()
|
||||
)
|
||||
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
||||
}
|
||||
@@ -186,7 +192,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initialHash = window.location.hash || ''
|
||||
initialHash = pageHash()
|
||||
if (framed) {
|
||||
// Pre-fetch the shared store from the embedder.
|
||||
try {
|
||||
@@ -249,7 +255,7 @@
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (data?.type === 'windmill:hashchange') {
|
||||
} else if (data?.type === 'windmill:hashchange' && syncHashToUrl) {
|
||||
// Keep the parent URL hash in sync for shareable URLs.
|
||||
const newHash = data.hash || ''
|
||||
if (window.location.hash !== newHash) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
isActiveSession = true,
|
||||
active = true,
|
||||
initialSelectedId
|
||||
@@ -23,6 +24,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
|
||||
@@ -124,15 +128,13 @@
|
||||
version={cell.saved.val?.version_id}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
onDetails={onSeeDetails}
|
||||
condensedHeader={true}
|
||||
customUi={{ topBar: { aiBuilder: false } }}
|
||||
onDeploy={() => {
|
||||
// FlowBuilder has no deploy toast and the session stays put, so toast
|
||||
// here, then sync the preview to deployed (pulls the new locks + version_id).
|
||||
// FlowBuilder has no deploy toast and the session stays put, so toast here.
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
|
||||
// Deploying clears the item's pending draft — refresh the Draft Count.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
runtime.itemDeployed(workspaceId, 'flow', path)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The View side of a session preview tab: the deployed item, rendered by the same
|
||||
* components the workspace `/scripts/get`, `/flows/get` and `/apps_raw/get` pages
|
||||
* render. Everything those pages reach — drawers, triggers, saved inputs — is scoped
|
||||
* to the session's workspace through `workspace`, and every navigation they attempt
|
||||
* is caught here and turned into a move inside the preview panel, so nothing takes
|
||||
* the browser out of the session.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import ScriptDetail from '$lib/components/details/ScriptDetail.svelte'
|
||||
import FlowDetail from '$lib/components/details/FlowDetail.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
import {
|
||||
parseHistoricalScriptEdit,
|
||||
parsePreviewItemRoute,
|
||||
previewLocationLabel,
|
||||
stripBase
|
||||
} from './previewRouter'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
kind,
|
||||
path,
|
||||
version,
|
||||
workspaceId,
|
||||
tabId,
|
||||
container,
|
||||
active = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
/** The preview tab hosting this view, which its in-place moves re-point. */
|
||||
tabId: string
|
||||
/** The tab's host element, which also holds the overlays the page portals out. */
|
||||
container: HTMLElement | undefined
|
||||
kind: SessionTargetKind
|
||||
path: string
|
||||
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
|
||||
version?: string
|
||||
workspaceId: string
|
||||
/** Whether this side is the one on screen. Hidden sides stay mounted, so this gates
|
||||
* the window-level keyboard handler — several instances listen on the window. */
|
||||
active?: boolean
|
||||
} = $props()
|
||||
|
||||
// This reads the deployed version over the API rather than from the editor cell the
|
||||
// chat mutates, so it is the one preview kind that does not self-sync (the invariant
|
||||
// `previewReload.ts` states for editors). A deploy from the editor topbar beside it
|
||||
// bumps this counter through `itemDeployed`; a deploy from the chat arrives
|
||||
// instead as `refresh()`, via `previewReload`'s item list. Neither covers a deploy made
|
||||
// outside the session (Compare & Deploy, the CLI, another browser tab).
|
||||
const revision = $derived(runtime.deployedRevision(kind, path))
|
||||
let reloadKey = $state(0)
|
||||
// Seeded from the current value on purpose: before the first run this holds "no deploy
|
||||
// seen yet", so a fresh mount must not count as one.
|
||||
let lastRevision = untrack(() => revision)
|
||||
$effect(() => {
|
||||
const rev = revision
|
||||
if (rev === lastRevision) return
|
||||
lastRevision = rev
|
||||
reloadKey++
|
||||
})
|
||||
|
||||
/** Refetch the deployed item. The host calls this for the reload signals that reach a
|
||||
* tab from outside it; nothing about becoming visible triggers it, so a form the reader
|
||||
* filled in survives switching tabs away and back. */
|
||||
export function refresh(): void {
|
||||
reloadKey++
|
||||
}
|
||||
|
||||
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
|
||||
|
||||
// Remounts the detail page below, which loads on mount.
|
||||
const loadKey = $derived(`${workspaceId}/${kind}/${path}/${version ?? ''}/${reloadKey}`)
|
||||
// Nothing deployed at this path. Stamped with the load it answers, so a refetch or a
|
||||
// re-pointed tab is never answered from an earlier load's verdict.
|
||||
let notFoundFor: string | undefined = $state(undefined)
|
||||
const notDeployed = $derived(notFoundFor === loadKey)
|
||||
function onLoadState(key: string, state: 'loaded' | 'not_found') {
|
||||
// A load that outlived its key (still in flight when the tab was re-pointed) answers
|
||||
// for an item no longer on screen, either way.
|
||||
if (key !== loadKey) return
|
||||
notFoundFor = state === 'not_found' ? key : undefined
|
||||
}
|
||||
|
||||
function toEditSide() {
|
||||
onNavigate(
|
||||
kind === 'flow'
|
||||
? `/flows/edit/${path}`
|
||||
: kind === 'script'
|
||||
? `/scripts/edit/${path}`
|
||||
: `/apps_raw/edit/${path}`
|
||||
)
|
||||
}
|
||||
|
||||
// Everything the detail pages navigate to, resolved against the preview panel: a run
|
||||
// opens as its own tab, another side or version of an item re-points this tab, and
|
||||
// any other page opens as a page tab. Nothing reaches the top-level router.
|
||||
function onNavigate(url: string): void {
|
||||
const clean = stripBase(url)
|
||||
const query = new URL(url, 'http://x').searchParams
|
||||
const run = clean.match(/^\/run\/([^/?#]+)$/)
|
||||
if (run) {
|
||||
const jobId = decodeURIComponent(run[1])
|
||||
runtime.manager.openRunInPreview?.({
|
||||
jobId,
|
||||
workspace: query.get('workspace') || workspaceId,
|
||||
label: previewLocationLabel(`/run/${jobId}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
const route = parsePreviewItemRoute(url)
|
||||
// Legacy drag-and-drop apps and historical script edits have no in-panel host, so they
|
||||
// fall through to a page tab.
|
||||
if (route && (route.kind !== 'app' || route.raw_app) && !parseHistoricalScriptEdit(url)) {
|
||||
// A script's version history addresses a version by putting its hash where the
|
||||
// path goes. Every real script path is `u/<user>/…` or `f/<folder>/…`, so a
|
||||
// segment with no slash is a hash — pinned in the query instead, or this tab's
|
||||
// identity would become that hash and it would stop being a tab on this script.
|
||||
const isVersionHash = route.kind === 'script' && !route.itemPath.includes('/')
|
||||
const target = {
|
||||
type: 'item' as const,
|
||||
item: {
|
||||
path: isVersionHash ? path : route.itemPath,
|
||||
summary: '',
|
||||
kind: route.kind,
|
||||
raw_app: route.raw_app
|
||||
},
|
||||
mode: route.mode,
|
||||
version: isVersionHash ? route.itemPath : (query.get('version') ?? undefined)
|
||||
}
|
||||
// This tab is the deployed side, so a link to an editor is a request for the other
|
||||
// side: it opens beside this one rather than replacing the page it was asked from.
|
||||
// Everything else the page links — another version, the head, a rename's new path —
|
||||
// is still this same view of the item, so it re-points in place.
|
||||
if (route.mode === 'edit') runtime.previewTabs.open(target)
|
||||
else runtime.previewTabs.navigate(target, tabId)
|
||||
return
|
||||
}
|
||||
runtime.previewTabs.open({
|
||||
type: 'page',
|
||||
href: `${base}${clean}${query.size ? `?${query}` : ''}`,
|
||||
label: previewLocationLabel(clean)
|
||||
})
|
||||
}
|
||||
|
||||
// Every in-app link under the tab, not only the ones that call `onNavigate`: a plain anchor
|
||||
// followed by the browser would take it out of the session. `container` is the tab's host,
|
||||
// which also holds the drawers the page portals out of this component. Listened for on
|
||||
// `body`, between the link's own handlers (Svelte delegates them to the app root inside it)
|
||||
// and SvelteKit's router (on the document element), so it leaves alone a click a handler
|
||||
// already took and beats the router to the rest. A `#` link is a control's own; followed,
|
||||
// it would write into the sessions page's hash.
|
||||
$effect(() => {
|
||||
const el = container
|
||||
if (!el) return
|
||||
function onClick(e: MouseEvent) {
|
||||
if (e.defaultPrevented || e.button !== 0) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const target = e.target as Element | null
|
||||
if (!target || !el!.contains(target)) return
|
||||
const a = target.closest?.('a[href]') as HTMLAnchorElement | null
|
||||
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
|
||||
const url = new URL(a.href, window.location.href)
|
||||
if (url.origin !== window.location.origin) return
|
||||
e.preventDefault()
|
||||
if (a.getAttribute('href')?.startsWith('#')) return
|
||||
onNavigate(`${url.pathname}${url.search}`)
|
||||
}
|
||||
document.body.addEventListener('click', onClick)
|
||||
return () => document.body.removeEventListener('click', onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Remount to refetch: both detail components load on mount, so a deploy lands here
|
||||
rather than as a refresh path of their own. -->
|
||||
{#key loadKey}
|
||||
{@const mountedKey = loadKey}
|
||||
{@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)}
|
||||
{#if notDeployed}
|
||||
<!-- This side shows the DEPLOYED item, and there isn't one. Without this the detail
|
||||
page renders nothing and the panel is simply blank, which reads as a failure
|
||||
rather than as the ordinary state of a draft nobody has deployed yet. -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div class="text-sm text-primary">Not deployed yet</div>
|
||||
<div class="text-xs text-tertiary max-w-sm">
|
||||
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
|
||||
version to view. Deploy it from the editor to see it here.
|
||||
</div>
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
|
||||
Back to editor
|
||||
</Button>
|
||||
</div>
|
||||
{:else if kind === 'script'}
|
||||
<ScriptDetail
|
||||
hash={path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else if kind === 'flow'}
|
||||
<FlowDetail
|
||||
{path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else}
|
||||
<InWorkspaceAppViewer
|
||||
workspace={workspaceId}
|
||||
{path}
|
||||
onEdit={() => onNavigate(`/apps_raw/edit/${path}`)}
|
||||
onLoadState={setLoadState}
|
||||
syncHashToUrl={false}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -60,7 +60,11 @@
|
||||
if (item) {
|
||||
runtime.previewTabs.open({
|
||||
type: 'item',
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
|
||||
// A list page only ever links what it runs — a schedule's script, a
|
||||
// trigger's flow — which is deployed by construction. Its deployed page
|
||||
// is what the reader clicking through is asking about.
|
||||
mode: item.mode ?? 'view'
|
||||
})
|
||||
} else {
|
||||
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
|
||||
|
||||
@@ -16,7 +16,7 @@ section or the flat layout.
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { Compass, FileText } from 'lucide-svelte'
|
||||
import { Compass, FileText, Pen } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
@@ -45,6 +45,7 @@ section or the flat layout.
|
||||
isArtifactKey,
|
||||
pageHref,
|
||||
pageKey,
|
||||
previewModeFor,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
|
||||
@@ -135,7 +136,11 @@ section or the flat layout.
|
||||
draftPath: storagePath !== d.path ? d.path : d.draftPath,
|
||||
summary: d.summary ?? '',
|
||||
kind: k,
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined,
|
||||
// A chat-scaffolded draft the workspace listers have never seen. It has
|
||||
// an editor and nothing else, so picking it must not aim at a deployed
|
||||
// page — and a row merged over a loaded one keeps the lister's answer.
|
||||
draftOnly: true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -145,14 +150,42 @@ section or the flat layout.
|
||||
|
||||
// Wrap item leaves as PreviewTargets so the tree can carry page leaves too.
|
||||
// Branch structure is untouched — only leaf `data` is re-tagged.
|
||||
//
|
||||
// `mode` is stamped here rather than left to the tab: picking is the reader
|
||||
// saying "show me this", so it lands on the deployed page, and `navigate`
|
||||
// would otherwise keep whichever side the re-pointed tab happened to be on.
|
||||
function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] {
|
||||
return nodes.map((n) =>
|
||||
n.type === 'leaf'
|
||||
? { ...n, data: { type: 'item', item: n.data } }
|
||||
? { ...n, data: { type: 'item', item: n.data, mode: previewModeFor(n.data) } }
|
||||
: { ...n, children: tagItems(n.children) }
|
||||
)
|
||||
}
|
||||
|
||||
// Open the item's editor. Offered on every item row, including the draft-only ones
|
||||
// a plain click already opens there: a row that drops the button to say "you were
|
||||
// getting this anyway" reads as one that cannot be edited.
|
||||
function editAction(leaf: DrillLeaf<PreviewTarget>) {
|
||||
const target = leaf.data
|
||||
if (target.type !== 'item') return undefined
|
||||
return {
|
||||
icon: Pen,
|
||||
label: 'Edit',
|
||||
title: `Edit ${workspaceItemDisplayPath(target.item)}`,
|
||||
run: () => onPick({ ...target, mode: 'edit' })
|
||||
}
|
||||
}
|
||||
|
||||
// What the row says about the item's deploy state, in the review dock's words so
|
||||
// the two never describe the same item differently.
|
||||
function stateBadge(leaf: DrillLeaf<PreviewTarget>) {
|
||||
const target = leaf.data
|
||||
if (target.type !== 'item') return undefined
|
||||
if (target.item.draftOnly) return { label: 'Draft only', color: 'indigo' as const }
|
||||
if (target.item.hasDraft) return { label: 'Draft', color: 'indigo' as const }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pageLeaf(p: (typeof PREVIEW_PAGES)[number]): DrillLeaf<PreviewTarget> {
|
||||
return {
|
||||
type: 'leaf',
|
||||
@@ -262,5 +295,7 @@ section or the flat layout.
|
||||
if (scope.length > 0) loader.ensureForScopeSegment(scope[0])
|
||||
}}
|
||||
onFilterChange={loader.onFilterChange}
|
||||
leafAction={editAction}
|
||||
leafBadge={stateBadge}
|
||||
{rootLoading}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
darkMode,
|
||||
fullscreen = false,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
onLoad
|
||||
}: {
|
||||
tab: SessionPreviewTab
|
||||
@@ -57,6 +58,9 @@
|
||||
darkMode: boolean
|
||||
/** A link click inside a live editor re-points the active preview tab. */
|
||||
onNavigate: (item: WorkspaceItem) => void
|
||||
/** An editor's `Exit & see details` — re-points this tab at the item's deployed
|
||||
* view instead of navigating out of the session. */
|
||||
onSeeDetails: (item: WorkspaceItem) => void
|
||||
/** Iframe finished loading — the page reads back its observed location. */
|
||||
onLoad: (frame: HTMLIFrameElement) => void
|
||||
} = $props()
|
||||
@@ -65,6 +69,32 @@
|
||||
// any editable item (script/flow/raw app) or a pipeline folder mounts its own
|
||||
// live editor.
|
||||
const slot = $derived(resolvePreviewTab(tab.url))
|
||||
// The item this tab is on and the side of it its URL addresses, whichever side that is.
|
||||
const itemKind = $derived(
|
||||
slot.kind === 'editor' ? slot.editorKind : slot.kind === 'viewer' ? slot.viewerKind : undefined
|
||||
)
|
||||
const itemPath = $derived(
|
||||
slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined
|
||||
)
|
||||
const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit')
|
||||
// A pinned deployed version, read from the tab URL rather than passed down: the tab
|
||||
// URL is the single record of where this tab points.
|
||||
const pinnedVersion = $derived(
|
||||
slot.kind === 'viewer'
|
||||
? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined)
|
||||
: undefined
|
||||
)
|
||||
|
||||
// The path an editor reports is its deployed one, which a staged rename can make
|
||||
// differ from this tab's — so the view opens on what actually exists.
|
||||
const seeDetails = (e: { path: string }) =>
|
||||
onSeeDetails({
|
||||
path: e.path,
|
||||
summary: '',
|
||||
kind: itemKind === 'raw_app' ? 'app' : (itemKind as 'script' | 'flow'),
|
||||
raw_app: itemKind === 'raw_app'
|
||||
})
|
||||
|
||||
// Where inside the editor the tab was opened on ("open this flow step in a
|
||||
// session"). Only the in-process editors need it handed over — an iframe tab
|
||||
// loads the URL whole, params included.
|
||||
@@ -82,6 +112,7 @@
|
||||
)
|
||||
|
||||
let frame: HTMLIFrameElement | undefined = $state()
|
||||
let viewer: { refresh: () => void } | undefined = $state()
|
||||
|
||||
// Pages whose theme we mirror on live toggles. Regular apps are the only item
|
||||
// route that resolves to an iframe (scripts/flows/raw apps mount live editors)
|
||||
@@ -110,9 +141,15 @@
|
||||
export function reload() {
|
||||
// A live editor shares the runtime store the chat mutates, so generic chat
|
||||
// edits are already reflected — no reload needed. Deploys refresh it via
|
||||
// each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the
|
||||
// each editor view's onDeploy → runtime.itemDeployed. So only the
|
||||
// iframe fallback (a separate page) has to be told to refresh.
|
||||
if (slot.kind === 'editor') return
|
||||
// The viewer is the exception to that invariant: it reads the deployed item over
|
||||
// the API, so nothing about a store mutation reaches it and it has to refetch.
|
||||
if (slot.kind === 'viewer') {
|
||||
viewer?.refresh()
|
||||
return
|
||||
}
|
||||
if (slot.kind === 'pageitem') {
|
||||
pageItemReloadNonce++
|
||||
return
|
||||
@@ -264,63 +301,90 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if slot.kind === 'editor' && mounted && runtime}
|
||||
{#if itemKind && itemPath && mounted && runtime}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if slot.editorKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default {runtime} path={slot.path} {workspaceId} {isActiveSession} {active} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
/>
|
||||
{/await}
|
||||
<!-- Only the side the tab shows is mounted. A hidden editor would be a second editor on
|
||||
the item's one cell that tab dedupe, reading URLs, cannot see; a hidden side's drawers
|
||||
portal into this shared host and would stay over the other. -->
|
||||
{#if mode === 'edit'}
|
||||
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface">
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if itemKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
onSeeDetails={seeDetails}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
onSeeDetails={seeDetails}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default {runtime} path={itemPath} {workspaceId} {isActiveSession} {active} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if itemKind !== 'pipeline'}
|
||||
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface">
|
||||
{#await import('./ItemViewerView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:this={viewer}
|
||||
{runtime}
|
||||
kind={itemKind}
|
||||
path={itemPath}
|
||||
version={pinnedVersion}
|
||||
{workspaceId}
|
||||
tabId={tab.id}
|
||||
container={overlayHostEl}
|
||||
active={active && !collapsed}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'pageitem' && mounted && runtime}
|
||||
|
||||
@@ -274,12 +274,7 @@
|
||||
{onNavigate}
|
||||
condensedHeader={true}
|
||||
onResetToDeployed={reloadDeployed}
|
||||
onDeploy={(e) => {
|
||||
// Sync the preview to deployed (raw apps deploy only from this editor).
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
|
||||
// Deploying clears the item's pending draft — refresh the Draft Count.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
}}
|
||||
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', e.path)}
|
||||
defaultSidebarCollapsed
|
||||
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
|
||||
defaultSplitWithPreview={false}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
onSeeDetails,
|
||||
fullscreen = false,
|
||||
isActiveSession = true,
|
||||
active = true
|
||||
@@ -23,6 +24,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
|
||||
/** Preview panel is in full screen: collapse the test pane in the narrow
|
||||
* side-by-side layout, reopen it when there's room in full screen. */
|
||||
fullscreen?: boolean
|
||||
@@ -114,16 +118,13 @@
|
||||
condensedHeader={true}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
{onSeeDetails}
|
||||
testPanelCollapsed={!fullscreen}
|
||||
onDeploy={(e) => {
|
||||
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
|
||||
// ignore e.stay since the session always stays). Toast, then sync the
|
||||
// preview to the deployed version.
|
||||
// ignore e.stay since the session always stays).
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path)
|
||||
// Deploying clears the item's pending draft — refresh the workspace
|
||||
// Draft Count so the session bar / compare page drop it immediately.
|
||||
invalidateWorkspaceDrafts(workspaceId)
|
||||
runtime.itemDeployed(workspaceId, 'script', e.path)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
// Routed on the storage `path` — the edit route's key, which for a draft-only
|
||||
// item is its `draft_<uuid>` path, not the friendly `displayPath`.
|
||||
function previewTargetFor(item: DeployItem): PreviewTarget | undefined {
|
||||
return previewTargetForDeployKind(item.deployKind, item.path)
|
||||
// `done` means this session already deployed it, so there is a deployed
|
||||
// version to show even though the row was built from a draft-only item.
|
||||
return previewTargetForDeployKind(item.deployKind, item.path, item.draftOnly && !item.done)
|
||||
}
|
||||
|
||||
// The row's primary action is the preview; kinds the panel can't host fall back
|
||||
|
||||
@@ -151,18 +151,38 @@ export function stripBase(path: string): string {
|
||||
return p || '/'
|
||||
}
|
||||
|
||||
export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string }
|
||||
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
|
||||
* deployed version. The two are one tab in a session, switched by re-pointing its URL. */
|
||||
export type PreviewItemMode = 'edit' | 'view'
|
||||
|
||||
// Parse a preview URL/pathname into the workspace item it edits, or null for a
|
||||
export type PreviewItemRoute = {
|
||||
kind: WorkspaceItemKind
|
||||
raw_app: boolean
|
||||
itemPath: string
|
||||
mode: PreviewItemMode
|
||||
}
|
||||
|
||||
// Parse a preview URL/pathname into the workspace item it addresses, or null for a
|
||||
// non-item page (home, runs, …). Shared by the breadcrumb (drill segments) and
|
||||
// `previewRouter`'s tab resolver so both agree on what counts as an item route.
|
||||
export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null {
|
||||
const p = stripBase(fullPath)
|
||||
const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(?:edit|get)\/(.+)$/)
|
||||
const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(edit|get)\/(.+)$/)
|
||||
if (!m) return null
|
||||
const itemPath = decodeURIComponent(m[2])
|
||||
if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath }
|
||||
if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath }
|
||||
if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath }
|
||||
return { kind: 'app', raw_app: false, itemPath }
|
||||
const itemPath = decodeURIComponent(m[3])
|
||||
const mode: PreviewItemMode = m[2] === 'get' ? 'view' : 'edit'
|
||||
if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath, mode }
|
||||
if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath, mode }
|
||||
if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath, mode }
|
||||
return { kind: 'app', raw_app: false, itemPath, mode }
|
||||
}
|
||||
|
||||
/** The script and hash of an editor opened on a historical version (`/scripts/edit/<path>?hash=`),
|
||||
* or null. The session's live editor holds only the current draft, so this route is the
|
||||
* standalone editor — which writes no draft in that mode — and never that editor's tab. */
|
||||
export function parseHistoricalScriptEdit(url: string): { path: string; hash: string } | null {
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (route?.kind !== 'script' || route.mode !== 'edit') return null
|
||||
const hash = new URL(url, 'http://x').searchParams.get('hash')
|
||||
return hash ? { path: route.itemPath, hash } : null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { toolReloadEffect, tabsToReload } from './previewReload'
|
||||
import { toolReloadEffect, tabsToReload, viewerTabsToReload } from './previewReload'
|
||||
import type { SessionPreviewTab } from './sessionState.svelte'
|
||||
import { pageItemUrl } from './previewPaths'
|
||||
|
||||
@@ -106,3 +106,66 @@ describe('tabsToReload', () => {
|
||||
expect(tabsToReload([navigated], new Set(['/schedules']))).toEqual([navigated])
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewerTabsToReload', () => {
|
||||
const viewTab: SessionPreviewTab = {
|
||||
id: 'v',
|
||||
url: '/scripts/get/f/foo/bar',
|
||||
loc: '/scripts/get/f/foo/bar'
|
||||
}
|
||||
const editTab: SessionPreviewTab = {
|
||||
id: 'e',
|
||||
url: '/flows/edit/f/foo/baz',
|
||||
loc: '/flows/edit/f/foo/baz'
|
||||
}
|
||||
const pageTab: SessionPreviewTab = { id: 'p', url: '/schedules', loc: '/schedules' }
|
||||
|
||||
it('returns the view-side tab whose item was deployed', () => {
|
||||
expect(viewerTabsToReload([viewTab, editTab, pageTab], new Set(['f/foo/bar']))).toEqual([
|
||||
viewTab
|
||||
])
|
||||
})
|
||||
|
||||
// The editor holds what the user is writing; refetching the deployed version over it
|
||||
// would discard that, and it self-syncs from the store the chat already mutated.
|
||||
it('never returns the edit side, even for the item that was deployed', () => {
|
||||
expect(viewerTabsToReload([editTab], new Set(['f/foo/baz']))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a view tab on a different item alone', () => {
|
||||
expect(viewerTabsToReload([viewTab], new Set(['f/other/item']))).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty when no items were deployed', () => {
|
||||
expect(viewerTabsToReload([viewTab, editTab], new Set())).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolReloadEffect deployed', () => {
|
||||
// A deploy is invisible to a View tab otherwise: it renders the deployed version over
|
||||
// the API and shares no store with the chat.
|
||||
it('names the item a deploy or delete changed, for the kinds a tab can view', () => {
|
||||
for (const type of ['script', 'flow', 'app']) {
|
||||
expect(toolReloadEffect('deploy_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([
|
||||
'u/me/x'
|
||||
])
|
||||
expect(toolReloadEffect('delete_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([
|
||||
'u/me/x'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('names no item for a draft-only tool — nothing deployed changed', () => {
|
||||
expect(toolReloadEffect('write_script', { path: 'u/me/x' }).deployed).toEqual([])
|
||||
expect(
|
||||
toolReloadEffect('discard_local_draft', { type: 'script', path: 'u/me/x' }).deployed
|
||||
).toEqual([])
|
||||
expect(toolReloadEffect('rebase_draft', { type: 'flow', path: 'u/me/x' }).deployed).toEqual([])
|
||||
})
|
||||
|
||||
it('names no item for types that have a list page instead of a view side', () => {
|
||||
expect(
|
||||
toolReloadEffect('deploy_workspace_item', { type: 'schedule', path: 'u/me/s' }).deployed
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
pageItemListPath,
|
||||
pageItemUrl,
|
||||
parsePageItemRoute,
|
||||
parsePreviewItemRoute,
|
||||
stripBase,
|
||||
TRIGGER_PAGES,
|
||||
type PageItemRef,
|
||||
@@ -26,8 +27,14 @@ import {
|
||||
// in-process tabs: their editors read a draft only when they open, so a write to
|
||||
// one reloads its tab too. `items` names it when the tool's args do; without a
|
||||
// path, every tab of that kind reloads.
|
||||
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[] }
|
||||
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [] }
|
||||
//
|
||||
// `deployed` is the other exception. A tab can sit on the VIEW side of an item,
|
||||
// which renders the DEPLOYED version read over the API — it shares no store with
|
||||
// the chat, so a tool that changes what is deployed at a path has to name it here
|
||||
// or that tab keeps showing the previous deployment. Only tools that change the
|
||||
// deployed item belong in `deployed`; a draft write does not.
|
||||
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] }
|
||||
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] }
|
||||
|
||||
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
switch (name) {
|
||||
@@ -41,13 +48,19 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
case 'write_variable':
|
||||
return withItem(['/variables'], itemRef('variable', args))
|
||||
case 'create_folder':
|
||||
return { pages: ['/folders'], items: [] }
|
||||
return { pages: ['/folders'], items: [], deployed: [] }
|
||||
// These two change what is deployed at a path, so a tab on that item's View side
|
||||
// is showing a version that no longer exists.
|
||||
case 'delete_workspace_item':
|
||||
case 'deploy_workspace_item':
|
||||
return {
|
||||
...withItem(pagesForItemType(args?.type, args), itemRef(args?.type, args)),
|
||||
deployed: deployedItems(args)
|
||||
}
|
||||
// Generic item tools carry a workspace-item `type`; refresh its list page
|
||||
// when it lives on one (schedule/resource/variable/trigger). script/flow/app
|
||||
// have their own live editor tab and no previewed list page → nothing.
|
||||
case 'delete_workspace_item':
|
||||
case 'discard_local_draft':
|
||||
case 'deploy_workspace_item':
|
||||
case 'rebase_draft':
|
||||
return withItem(
|
||||
pagesForItemType(args?.type, args),
|
||||
@@ -58,8 +71,16 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
|
||||
}
|
||||
}
|
||||
|
||||
// The item path a deploy/delete changed, when it is one of the kinds a preview tab can
|
||||
// show the deployed side of. Other types (schedule, resource, …) have list pages instead.
|
||||
function deployedItems(args: any): string[] {
|
||||
const type = args?.type
|
||||
if (type !== 'script' && type !== 'flow' && type !== 'app') return []
|
||||
return typeof args?.path === 'string' && args.path ? [args.path] : []
|
||||
}
|
||||
|
||||
function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect {
|
||||
return { pages, items: item && pages.length ? [item] : [] }
|
||||
return { pages, items: item && pages.length ? [item] : [], deployed: [] }
|
||||
}
|
||||
|
||||
function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined {
|
||||
@@ -114,3 +135,17 @@ export function tabsToReload(
|
||||
return !named || items.has(pageItemUrl(pageItem))
|
||||
})
|
||||
}
|
||||
|
||||
// The open tabs a deploy/delete of `items` should refresh: those sitting on the VIEW side
|
||||
// of one of those paths. The Edit side is excluded on purpose — it is the live editor that
|
||||
// self-syncs, and reloading it would throw away whatever is being edited.
|
||||
export function viewerTabsToReload(
|
||||
tabs: SessionPreviewTab[],
|
||||
items: ReadonlySet<string>
|
||||
): SessionPreviewTab[] {
|
||||
if (items.size === 0) return []
|
||||
return tabs.filter((t) => {
|
||||
const route = parsePreviewItemRoute(whereIs(t))
|
||||
return route?.mode === 'view' && items.has(route.itemPath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,12 +13,28 @@ import {
|
||||
parseRunFormRoute,
|
||||
previewLocationContext,
|
||||
previewLocationLabel,
|
||||
previewModeFor,
|
||||
resolvePreviewTab,
|
||||
runFormUrl,
|
||||
workspacePageHref
|
||||
} from './previewRouter'
|
||||
import { pageItemUrl } from './previewPaths'
|
||||
|
||||
describe('previewModeFor', () => {
|
||||
// Every opener that shows the reader an item that already exists routes through
|
||||
// this, so the two halves are one decision rather than a repeated conditional.
|
||||
it('sends a deployed item to its page and a draft-only one to its editor', () => {
|
||||
expect(previewModeFor({ draftOnly: false })).toBe('view')
|
||||
expect(previewModeFor({ draftOnly: true })).toBe('edit')
|
||||
})
|
||||
|
||||
// A row from a lister that was not asked for draft-only items carries no flag,
|
||||
// and everything it lists is deployed.
|
||||
it('treats a missing flag as deployed', () => {
|
||||
expect(previewModeFor({})).toBe('view')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspacePageHref', () => {
|
||||
it('sends a page item tab to its list page with the row open, never to its scheme', () => {
|
||||
expect(workspacePageHref(pageItemUrl({ kind: 'resource', path: 'u/me/db' }))).toBe(
|
||||
@@ -214,22 +230,38 @@ describe('parsePreviewItemRoute', () => {
|
||||
expect(parsePreviewItemRoute('/scripts/edit/f/foo/bar')).toEqual({
|
||||
kind: 'script',
|
||||
raw_app: false,
|
||||
itemPath: 'f/foo/bar'
|
||||
itemPath: 'f/foo/bar',
|
||||
mode: 'edit'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/flows/get/u/admin/baz')).toEqual({
|
||||
kind: 'flow',
|
||||
raw_app: false,
|
||||
itemPath: 'u/admin/baz'
|
||||
itemPath: 'u/admin/baz',
|
||||
mode: 'view'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/apps_raw/edit/f/a/b')).toEqual({
|
||||
kind: 'app',
|
||||
raw_app: true,
|
||||
itemPath: 'f/a/b'
|
||||
itemPath: 'f/a/b',
|
||||
mode: 'edit'
|
||||
})
|
||||
expect(parsePreviewItemRoute('/apps/edit/f/a/b')).toEqual({
|
||||
kind: 'app',
|
||||
raw_app: false,
|
||||
itemPath: 'f/a/b'
|
||||
itemPath: 'f/a/b',
|
||||
mode: 'edit'
|
||||
})
|
||||
})
|
||||
|
||||
// The two sides are one tab, keyed by the item path: a query — `?version=` pinning an
|
||||
// old deployed version among them — must not read as part of the path, or the tab
|
||||
// would count as a different item and stop deduping against itself.
|
||||
it('reads the side from the segment and ignores the query', () => {
|
||||
expect(parsePreviewItemRoute('/scripts/get/f/foo/bar?version=42')).toEqual({
|
||||
kind: 'script',
|
||||
raw_app: false,
|
||||
itemPath: 'f/foo/bar',
|
||||
mode: 'view'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -303,6 +335,15 @@ describe('resolvePreviewTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an edit of a historical script version to the standalone editor', () => {
|
||||
const historical = '/scripts/edit/f/foo/bar?hash=abc123&topHash=def456'
|
||||
expect(resolvePreviewTab(historical)).toEqual({ kind: 'iframe' })
|
||||
// Sharing the live editor's identity would let opening it re-point that editor's tab.
|
||||
expect(describeLocation(historical).identity).not.toBe(
|
||||
describeLocation('/scripts/edit/f/foo/bar').identity
|
||||
)
|
||||
})
|
||||
|
||||
it('routes any flow item to a live editor', () => {
|
||||
expect(resolvePreviewTab('/flows/edit/f/foo/bar')).toEqual({
|
||||
kind: 'editor',
|
||||
@@ -319,8 +360,30 @@ describe('resolvePreviewTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// `/get/` used to resolve to the editor, so a tab pointed at a deployed item mounted
|
||||
// the thing that edits it. The side now comes from the segment, which is what lets one
|
||||
// tab hold both.
|
||||
it('routes the /get/ side of each wrappable kind to its viewer', () => {
|
||||
expect(resolvePreviewTab('/scripts/get/f/foo/bar')).toEqual({
|
||||
kind: 'viewer',
|
||||
viewerKind: 'script',
|
||||
path: 'f/foo/bar'
|
||||
})
|
||||
expect(resolvePreviewTab('/flows/get/f/foo/bar')).toEqual({
|
||||
kind: 'viewer',
|
||||
viewerKind: 'flow',
|
||||
path: 'f/foo/bar'
|
||||
})
|
||||
expect(resolvePreviewTab('/apps_raw/get/f/a/b')).toEqual({
|
||||
kind: 'viewer',
|
||||
viewerKind: 'raw_app',
|
||||
path: 'f/a/b'
|
||||
})
|
||||
})
|
||||
|
||||
it('never routes a regular drag-and-drop app to an editor (no wrapper exists)', () => {
|
||||
expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' })
|
||||
expect(resolvePreviewTab('/apps/get/f/a/b')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('routes a pipeline folder to the pipeline editor kind', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
pageKey,
|
||||
pageHref,
|
||||
parsePageItemRoute,
|
||||
parseHistoricalScriptEdit,
|
||||
parsePreviewItemRoute,
|
||||
RESOURCES_PATH,
|
||||
RUNS_PATH,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
triggerLabelForPath,
|
||||
TRIGGER_PAGES,
|
||||
type PageItemRef,
|
||||
type PreviewItemMode,
|
||||
type PreviewItemRoute,
|
||||
type TriggerKind
|
||||
} from './previewPaths'
|
||||
@@ -31,11 +33,13 @@ export {
|
||||
pageItemUrl,
|
||||
pageKey,
|
||||
pageHref,
|
||||
parseHistoricalScriptEdit,
|
||||
parsePageItemRoute,
|
||||
parsePreviewItemRoute,
|
||||
stripBase,
|
||||
TRIGGER_PAGES,
|
||||
type PageItemRef,
|
||||
type PreviewItemMode,
|
||||
type PreviewItemRoute,
|
||||
type TriggerKind
|
||||
}
|
||||
@@ -76,11 +80,28 @@ export type ArtifactVersionTarget = number | 'latest'
|
||||
* an iframe URL. */
|
||||
export type PreviewTarget =
|
||||
| { type: 'page'; href: string; label: string }
|
||||
| { type: 'item'; item: WorkspaceItem }
|
||||
// `mode` picks the side of the item to land on; omitted means Edit, so every
|
||||
// caller that predates the View side keeps opening the editor. `version` pins the
|
||||
// View side to one deployed version, in the query so the tab's identity stays the
|
||||
// item's path rather than becoming the version's own hash.
|
||||
| { type: 'item'; item: WorkspaceItem; mode?: PreviewItemMode; version?: string }
|
||||
| { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget }
|
||||
| { type: 'runform'; toolCallId: string; label: string }
|
||||
| { type: 'pageitem'; ref: PageItemRef }
|
||||
|
||||
/**
|
||||
* Which side of an item to open when the opener is showing the reader something
|
||||
* that already exists, rather than something it just wrote: the deployed page,
|
||||
* unless nothing is deployed there and the editor is all the item has.
|
||||
*
|
||||
* The openers that follow a write (`open_preview`, the write-result preview card)
|
||||
* pass `'edit'` themselves — the item they mean may well be deployed, and its
|
||||
* deployed page is precisely not what the reader asked to see.
|
||||
*/
|
||||
export function previewModeFor(item: Pick<WorkspaceItem, 'draftOnly'>): PreviewItemMode {
|
||||
return item.draftOnly ? 'edit' : 'view'
|
||||
}
|
||||
|
||||
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
|
||||
|
||||
// Core workspace-level destinations the preview can route to. Intentionally
|
||||
@@ -235,6 +256,11 @@ export function describeLocation(loc: string): PreviewLocation {
|
||||
if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' }
|
||||
const pageItem = parsePageItemRoute(loc)
|
||||
if (pageItem) return { identity: pageItemUrl(pageItem), view: '', anchor: '' }
|
||||
// Keyed by its hash: as a bare path it would claim the live editor's tab on the same script.
|
||||
const historical = parseHistoricalScriptEdit(loc)
|
||||
if (historical) {
|
||||
return { identity: `/scripts/edit/${historical.path}@${historical.hash}`, view: '', anchor: '' }
|
||||
}
|
||||
const canonical = canonicalizeObservedLoc(loc)
|
||||
const path = stripBase(canonical)
|
||||
const bare = canonical.split('#')[0]
|
||||
@@ -420,9 +446,15 @@ export function previewLocationLabel(url: string): string {
|
||||
const trigger = triggerLabelForPath(url)
|
||||
if (trigger) return trigger
|
||||
const run = stripBase(url).match(/^\/run\/([^/?#]+)/)
|
||||
if (run) return `Run ${decodeURIComponent(run[1]).slice(0, 8)}`
|
||||
// Job ids are time-ordered, so runs started moments apart share their head.
|
||||
if (run) return `Run ${decodeURIComponent(run[1]).slice(-8)}`
|
||||
// The runs of one item, as a detail page's Runs button links them.
|
||||
const itemRuns = stripBase(url).match(/^\/runs\/(.+)$/)
|
||||
if (itemRuns) return `Runs · ${decodeURIComponent(itemRuns[1]).split('/').pop()}`
|
||||
const pipelineFolder = parsePipelineRoute(url)
|
||||
if (pipelineFolder) return pipelineFolder
|
||||
const historical = parseHistoricalScriptEdit(url)
|
||||
if (historical) return `${historical.path.split('/').pop()} @ ${historical.hash.slice(0, 8)}`
|
||||
const parsed = parsePreviewItemRoute(url)
|
||||
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
|
||||
return stripBase(url)
|
||||
@@ -527,14 +559,16 @@ export const artifactKey = (id: string) => `artifact:${id}`
|
||||
|
||||
export const isArtifactKey = (key: string) => key.startsWith('artifact:')
|
||||
|
||||
// How a preview tab should render: as an in-process live editor or an iframe
|
||||
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
|
||||
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
|
||||
// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path`
|
||||
// is the folder); the list page of a page item kind mounts that list; everything
|
||||
// How a preview tab should render: as an in-process live editor, the deployed
|
||||
// item's view page, or an iframe fallback. An item of a wrappable kind (script,
|
||||
// flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer
|
||||
// on `/get/`; a `/pipeline/<folder>` route mounts the data-pipeline graph editor
|
||||
// (single, shared runtime.pipelineEditorState — `path` is the folder); the list page
|
||||
// of a page item kind mounts that list; everything
|
||||
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
|
||||
export type PreviewSlot =
|
||||
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
|
||||
| { kind: 'viewer'; viewerKind: SessionTargetKind; path: string }
|
||||
| { kind: 'artifact'; id: string; version?: number }
|
||||
| { kind: 'runform'; toolCallId: string }
|
||||
| { kind: 'pageitem'; ref: PageItemRef }
|
||||
@@ -555,8 +589,8 @@ export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
|
||||
}
|
||||
const item = parsePreviewItemRoute(url)
|
||||
if (!item) return { kind: 'iframe' }
|
||||
const editorKind: SessionTargetKind | undefined =
|
||||
if (!item || parseHistoricalScriptEdit(url)) return { kind: 'iframe' }
|
||||
const itemKind: SessionTargetKind | undefined =
|
||||
item.kind === 'script'
|
||||
? 'script'
|
||||
: item.kind === 'flow'
|
||||
@@ -564,8 +598,9 @@ export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
: item.kind === 'app' && item.raw_app
|
||||
? 'raw_app'
|
||||
: undefined
|
||||
if (!editorKind) return { kind: 'iframe' }
|
||||
return { kind: 'editor', editorKind, path: item.itemPath }
|
||||
if (!itemKind) return { kind: 'iframe' }
|
||||
if (item.mode === 'view') return { kind: 'viewer', viewerKind: itemKind, path: item.itemPath }
|
||||
return { kind: 'editor', editorKind: itemKind, path: item.itemPath }
|
||||
}
|
||||
|
||||
/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when
|
||||
@@ -580,6 +615,7 @@ export function workspacePageHref(location: string): string | undefined {
|
||||
case 'pageitem':
|
||||
return pageItemPageHref(slot.ref)
|
||||
case 'editor':
|
||||
case 'viewer':
|
||||
case 'pagelist':
|
||||
case 'iframe':
|
||||
return location
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base } from '$lib/base'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { editPathFor, type WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { editPathFor, viewPathFor, type WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
|
||||
import {
|
||||
artifactUrl,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
runFormUrl,
|
||||
stripBase,
|
||||
type ArtifactVersionTarget,
|
||||
type PreviewItemMode,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
|
||||
@@ -60,14 +61,27 @@ export type PreviewTabsAdapter = {
|
||||
onTabOpened?: (url: string) => void
|
||||
}
|
||||
|
||||
// True when a tab's URL is the live editor for a specific editable item. Every
|
||||
// editable route resolves to an editor, so this doubles as the "same item" dedupe
|
||||
// test in open()/navigate().
|
||||
function isEditorTabFor(url: string, target: SessionTarget): boolean {
|
||||
// True when a tab's URL is on one SIDE of a specific editable item. The two sides are
|
||||
// separate tabs, so this is the dedupe test in open()/navigate(): asking for a side that
|
||||
// is already open focuses it, asking for the other one opens it alongside.
|
||||
//
|
||||
// Keying on the side is also what keeps the editor cell safe. Two editor tabs on one item
|
||||
// would mount two editors racing the same (kind, path) cell; an editor beside a viewer is
|
||||
// fine, because the viewer reads the deployed version over the API and touches no cell.
|
||||
function isItemTabFor(url: string, target: SessionTarget, side: PreviewItemMode): boolean {
|
||||
const slot = resolvePreviewTab(url)
|
||||
if (side === 'view') {
|
||||
return slot.kind === 'viewer' && slot.viewerKind === target.kind && slot.path === target.path
|
||||
}
|
||||
return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path
|
||||
}
|
||||
|
||||
/** Which side a target asks for. Openers that predate the split omit `mode`, and the editor
|
||||
* is what they have always meant. */
|
||||
function sideOf(target: PreviewTarget): PreviewItemMode {
|
||||
return target.type === 'item' && target.mode === 'view' ? 'view' : 'edit'
|
||||
}
|
||||
|
||||
// The version a tab shows when it is re-pointed: the opener's, if it named one, else whatever
|
||||
// pin is already on the tab. Re-pointing must never *silently* double as "show the newest" —
|
||||
// every artifact tool re-opens the document it just wrote, so that would yank the reader out of
|
||||
@@ -95,7 +109,14 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string {
|
||||
return artifactUrl(target.id, target.name, keptVersion(target, onto))
|
||||
}
|
||||
if (target.type === 'runform') return runFormUrl(target.toolCallId, target.label)
|
||||
return `${base}${editPathFor(target.item)}`
|
||||
// A re-point with no mode keeps the side the tab is already on, so an AI write to
|
||||
// an item the user left on View does not yank them back to the editor.
|
||||
const mode = target.mode ?? (onto ? (parsePreviewItemRoute(onto.url)?.mode ?? 'edit') : 'edit')
|
||||
if (mode !== 'view') return `${base}${editPathFor(target.item)}`
|
||||
// Not carried over from `onto`: a version pin is something the reader asked for
|
||||
// once, so every other re-point of this tab lands on the current deployed version.
|
||||
const pin = target.version ? `?version=${encodeURIComponent(target.version)}` : ''
|
||||
return `${base}${viewPathFor(target.item)}${pin}`
|
||||
}
|
||||
|
||||
// Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath`
|
||||
@@ -168,12 +189,23 @@ export function previewTargetForSessionTarget(
|
||||
// drag-and-drop apps, which the panel hosts as an iframe over their edit route.
|
||||
// Every other kind maps to undefined, and its row falls back to the diff. The
|
||||
// undefined is also the caller's test for "can this row be previewed?".
|
||||
export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTarget | undefined {
|
||||
export function previewTargetForDeployKind(
|
||||
kind: Kind,
|
||||
path: string,
|
||||
/** Whether nothing is deployed at `path`. The review dock reads this off the
|
||||
* row it is about to open, so an item side is chosen the same way the picker
|
||||
* chooses it; omitted, the tab falls back to the editor as before. */
|
||||
draftOnly?: boolean
|
||||
): PreviewTarget | undefined {
|
||||
const mode: PreviewItemMode | undefined =
|
||||
draftOnly === undefined ? undefined : draftOnly ? 'edit' : 'view'
|
||||
if (kind === 'app') {
|
||||
return { type: 'item', item: { kind: 'app', raw_app: false, path, summary: '' } }
|
||||
return { type: 'item', item: { kind: 'app', raw_app: false, path, summary: '' }, mode }
|
||||
}
|
||||
if (kind === 'script' || kind === 'flow' || kind === 'raw_app') {
|
||||
return previewTargetForSessionTarget(kind, path)
|
||||
const target = previewTargetForSessionTarget(kind, path)
|
||||
if (target?.type !== 'item') return target
|
||||
return { type: 'item', item: target.item, mode }
|
||||
}
|
||||
if (kind === 'variable' || kind === 'resource' || kind === 'schedule') {
|
||||
return { type: 'pageitem', ref: { kind, path } }
|
||||
@@ -412,8 +444,9 @@ export class SessionPreviewTabs {
|
||||
// collapsed panel and the user sees nothing change.
|
||||
this.#collapsed = false
|
||||
if (editorTarget) {
|
||||
// One editor tab per item: focus the tab already hosting this exact item.
|
||||
const existing = this.#tabs.find((t) => isEditorTabFor(t.url, editorTarget))
|
||||
// One tab per side: focus the tab already showing this side of this item. The
|
||||
// other side, if open, is a tab of its own and is left where it is.
|
||||
const existing = this.#tabs.find((t) => isItemTabFor(t.url, editorTarget, sideOf(target)))
|
||||
if (existing) {
|
||||
this.#activeId = existing.id
|
||||
this.#flush()
|
||||
@@ -487,19 +520,22 @@ export class SessionPreviewTabs {
|
||||
|
||||
// Re-point the active tab at a destination (breadcrumb pick / in-editor link /
|
||||
// iframe-posted editor navigation).
|
||||
navigate(target: PreviewTarget): void {
|
||||
this.#pulsingIfUnchanged(() => this.#navigate(target))
|
||||
// `tabId` names the tab to re-point when the request comes from a tab's own content, which
|
||||
// can finish after the reader has moved to another tab, or run in a tab that is hidden.
|
||||
navigate(target: PreviewTarget, tabId?: string): void {
|
||||
this.#pulsingIfUnchanged(() => this.#navigate(target, tabId))
|
||||
}
|
||||
|
||||
#navigate(target: PreviewTarget): void {
|
||||
const t = this.#tabs.find((x) => x.id === this.#activeId)
|
||||
#navigate(target: PreviewTarget, tabId?: string): void {
|
||||
const t = this.#tabs.find((x) => x.id === (tabId ?? this.#activeId))
|
||||
if (!t) return
|
||||
const editorTarget = editorTargetFor(target)
|
||||
if (editorTarget) {
|
||||
// Same dedupe as open(): if another tab already hosts this exact item,
|
||||
// focus it instead of re-pointing this one — two tabs for one item would
|
||||
// mount two editors racing the same (kind, path) cell.
|
||||
const existing = this.#tabs.find((x) => isEditorTabFor(x.url, editorTarget))
|
||||
// Same dedupe as open(): another tab already showing this side of this item is
|
||||
// focused rather than duplicated. Re-pointing THIS tab to the other side of the
|
||||
// item it already shows falls through to the retarget below — the breadcrumb
|
||||
// redirects the tab you are on, it does not spawn one.
|
||||
const existing = this.#tabs.find((x) => isItemTabFor(x.url, editorTarget, sideOf(target)))
|
||||
if (existing && existing.id !== t.id) {
|
||||
this.#activeId = existing.id
|
||||
this.#flush()
|
||||
@@ -671,7 +707,10 @@ export class SessionPreviewTabs {
|
||||
label: string | undefined,
|
||||
friendlyPath?: string
|
||||
): void {
|
||||
const t = this.#tabs.find((x) => isEditorTabFor(x.url, target))
|
||||
// The editor's tab, never the viewer's: this is the live editor stamping the name it
|
||||
// is holding, including a rename staged only in the draft. The deployed page beside
|
||||
// it still answers to the deployed name.
|
||||
const t = this.#tabs.find((x) => isItemTabFor(x.url, target, 'edit'))
|
||||
if (!t) return
|
||||
// Set before the no-change early return: an item with neither a summary nor
|
||||
// a staged path leaves both fields undefined, and the editor still owns it.
|
||||
@@ -782,7 +821,12 @@ export function describePreview(
|
||||
// their `#<path>` is the trigger the drawer has open.
|
||||
`${stripBase(where)}${previewLocationDetail(where)}`
|
||||
const slotKind = resolvePreviewTab(t.url).kind
|
||||
const live = slotKind === 'editor' || slotKind === 'pageitem' ? ', live editor' : ''
|
||||
const live =
|
||||
slotKind === 'editor' || slotKind === 'pageitem'
|
||||
? ', live editor'
|
||||
: slotKind === 'viewer'
|
||||
? ', deployed view'
|
||||
: ''
|
||||
const active = t.id === activeId ? ', active' : ''
|
||||
// One list entry per tab: an artifact's name, a pipeline folder and an item path
|
||||
// all arrive decoded from a URL, so any of them could otherwise write a line here.
|
||||
|
||||
@@ -531,6 +531,17 @@ describe('SessionPreviewTabs.navigate', () => {
|
||||
expect(o.tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-points the named tab, not the one the reader has since moved to', () => {
|
||||
const o = owner()
|
||||
o.open(pageTarget)
|
||||
const origin = o.activeId
|
||||
o.open(artifactTarget)
|
||||
const moved = { ...flowTarget, mode: 'view' } as PreviewTarget
|
||||
o.navigate(moved, origin)
|
||||
expect(o.tabs.find((t) => t.id === origin)!.url).toBe('/flows/get/u/me/bar')
|
||||
expect(o.activeTab!.url).toBe(artifactUrl('art1', 'Plan'))
|
||||
})
|
||||
|
||||
it('retargets to a page', () => {
|
||||
const o = owner()
|
||||
o.open(scriptTarget)
|
||||
@@ -1114,3 +1125,40 @@ describe('SessionPreviewTabs.pulseFocus', () => {
|
||||
expect(o.focusPulse.nonce).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the two sides of an item are separate tabs', () => {
|
||||
const viewTarget: PreviewTarget = { ...scriptTarget, mode: 'view' } as PreviewTarget
|
||||
|
||||
// The editor and the deployed page are different things to look at, so opening one
|
||||
// never costs you the other — the reason the dedupe keys on the side and not the item.
|
||||
it('opens a tab per side and focuses the side already open', () => {
|
||||
const o = owner()
|
||||
expect(o.open(scriptTarget).status).toBe('opened')
|
||||
expect(o.open(viewTarget).status).toBe('opened')
|
||||
expect(o.tabs.length).toBe(2)
|
||||
expect(o.open(scriptTarget).status).toBe('focused')
|
||||
expect(o.open(viewTarget).status).toBe('focused')
|
||||
expect(o.tabs.length).toBe(2)
|
||||
})
|
||||
|
||||
// Two editor tabs on one item would mount two editors racing its single (kind, path)
|
||||
// cell; one editor beside one viewer does not, because the viewer holds no cell.
|
||||
it('never opens a second editor for the same item', () => {
|
||||
const o = owner()
|
||||
o.open(scriptTarget)
|
||||
o.open(scriptTarget)
|
||||
expect(o.tabs.filter((t) => t.url.includes('/scripts/edit/')).length).toBe(1)
|
||||
})
|
||||
|
||||
// The breadcrumb redirects the tab you are on. Sending it to the other side of the item
|
||||
// it already shows must not leave a duplicate behind.
|
||||
it('re-points in place when navigating a tab to its other side', () => {
|
||||
const o = owner()
|
||||
o.open(scriptTarget)
|
||||
const id = o.tabs[0].id
|
||||
o.navigate(viewTarget)
|
||||
expect(o.tabs.length).toBe(1)
|
||||
expect(o.tabs[0].id).toBe(id)
|
||||
expect(o.tabs[0].url).toContain('/scripts/get/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import { get } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte'
|
||||
import { initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
@@ -65,7 +67,8 @@ import {
|
||||
previewLocationLabel,
|
||||
promptSafe,
|
||||
parseRunFormRoute,
|
||||
resolvePreviewTab
|
||||
resolvePreviewTab,
|
||||
type PreviewSlot
|
||||
} from './previewRouter'
|
||||
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
@@ -184,6 +187,16 @@ export interface RawAppCell {
|
||||
saved: { val: RawAppSavedValue | undefined }
|
||||
}
|
||||
|
||||
// `feature_usage` key for a preview tab landing somewhere. The View keys are
|
||||
// disjoint from the six a tab open already reports, so the existing counters keep
|
||||
// their meaning and `view_*` answers how much the View side is used at all.
|
||||
function tabUsageKey(slot: PreviewSlot): string {
|
||||
if (slot.kind === 'editor') return slot.editorKind
|
||||
if (slot.kind === 'viewer') return `view_${slot.viewerKind}`
|
||||
if (slot.kind === 'artifact') return 'artifact'
|
||||
return slot.kind === 'runform' ? 'run_form' : 'page'
|
||||
}
|
||||
|
||||
export interface SessionRuntime {
|
||||
readonly sessionId: string
|
||||
readonly manager: AIChatManager
|
||||
@@ -206,6 +219,13 @@ export interface SessionRuntime {
|
||||
// exists yet for this (kind, path)), so callers can check load state without
|
||||
// the cell accessors' create-on-miss side effect.
|
||||
loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined
|
||||
/** Deploy counter for an item, bumped by `itemDeployed`. A mounted
|
||||
* viewer refetches when this changes — it reads the deployed version from the
|
||||
* API, so nothing else tells it the editor beside it just published. */
|
||||
deployedRevision(kind: SessionTargetKind, path: string): number
|
||||
/** Record that a preview tab landed on `url`. Fires for a newly opened tab and
|
||||
* for an existing one switched to the other side of its item. */
|
||||
logTabUsage(url: string): void
|
||||
loadRawApp(
|
||||
workspace: string,
|
||||
path: string,
|
||||
@@ -237,13 +257,13 @@ export interface SessionRuntime {
|
||||
/** Release the slot only if `requester` still owns it. */
|
||||
clearScreenshotRequester(requester: RawAppScreenshotRequester): void
|
||||
requestScreenshot(): Promise<string | undefined>
|
||||
// Discard the local draft + force-reload the editor, so the preview matches
|
||||
// the deployed version. Used by editor onDeploy + the chat deploy handler.
|
||||
syncPreviewWithDeployed(
|
||||
workspace: string,
|
||||
kind: 'script' | 'flow' | 'raw_app',
|
||||
path: string
|
||||
): void
|
||||
/** Everything this session must do once an item has been deployed from inside it:
|
||||
* discard the local draft, force-reload the editor so the preview matches what is
|
||||
* deployed, bump the viewer's revision, and drop the workspace caches whose answers
|
||||
* the deploy just changed. One call rather than a sequence at each deploy site — a
|
||||
* caller that remembers three of the four leaves a stale draft count or a picker
|
||||
* that still calls the item a draft. */
|
||||
itemDeployed(workspace: string, kind: 'script' | 'flow' | 'raw_app', path: string): void
|
||||
}
|
||||
|
||||
const runtimes = new SvelteMap<string, SessionRuntime>()
|
||||
@@ -408,7 +428,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// knows the page (and the row whose drawer is open) without spending a
|
||||
// get_preview_status round-trip. Live editors are skipped: they register
|
||||
// themselves as the ACTIVE EDITOR through UserDraft's live-draft registry. A page
|
||||
// item tab is not one of them, and reads as its list page with the item open.
|
||||
// item tab is not one of them, and reads as its list page with the item open; a
|
||||
// deployed item's page is not either, and reads as its `/get/` location.
|
||||
manager.activePreviewResolver = () => {
|
||||
const owner = getRuntime(session.id)?.previewTabs
|
||||
// What is on screen, not merely which tab is selected: the rule tells the model
|
||||
@@ -417,7 +438,12 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
const tab = owner?.displayedTab
|
||||
if (!tab) return undefined
|
||||
const slotKind = resolvePreviewTab(tab.url).kind
|
||||
if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist')
|
||||
if (
|
||||
slotKind !== 'iframe' &&
|
||||
slotKind !== 'pageitem' &&
|
||||
slotKind !== 'pagelist' &&
|
||||
slotKind !== 'viewer'
|
||||
)
|
||||
return undefined
|
||||
return previewLocationContext(whereIs(tab))
|
||||
}
|
||||
@@ -481,6 +507,22 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
if (!c) rawAppCells.set(path, (c = makeRawAppCell()))
|
||||
return c
|
||||
}
|
||||
// Bumped on every deploy of an item, so a mounted viewer — which reads the
|
||||
// deployed version over the API rather than from the editor cell — knows its copy
|
||||
// is stale. Keyed by (kind, path) and never pruned: a counter per item the session
|
||||
// deployed is a few bytes, and dropping one would replay its bump as "unchanged".
|
||||
const deployedRevisions = $state<Record<string, number>>({})
|
||||
const revisionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
|
||||
function deployedRevision(kind: SessionTargetKind, path: string): number {
|
||||
return deployedRevisions[revisionKey(kind, path)] ?? 0
|
||||
}
|
||||
function logTabUsage(url: string): void {
|
||||
logFeatureUsage('ai_session', 'tab', {
|
||||
key: tabUsageKey(resolvePreviewTab(url)),
|
||||
entityId: session.id,
|
||||
workspace: getEffectiveWorkspaceId(session)
|
||||
})
|
||||
}
|
||||
function loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined {
|
||||
const cell =
|
||||
kind === 'flow'
|
||||
@@ -519,21 +561,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize)
|
||||
},
|
||||
onTabsChanged: pruneEditorCells,
|
||||
onTabOpened: (url) => {
|
||||
const slot = resolvePreviewTab(url)
|
||||
logFeatureUsage('ai_session', 'tab', {
|
||||
key:
|
||||
slot.kind === 'editor'
|
||||
? slot.editorKind
|
||||
: slot.kind === 'artifact'
|
||||
? 'artifact'
|
||||
: slot.kind === 'runform'
|
||||
? 'run_form'
|
||||
: 'page',
|
||||
entityId: session.id,
|
||||
workspace: getEffectiveWorkspaceId(session)
|
||||
})
|
||||
}
|
||||
onTabOpened: logTabUsage
|
||||
})
|
||||
|
||||
// Let the jobs tray open a run in this session's preview panel (as an iframe
|
||||
@@ -597,6 +625,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
pipelineEditorState,
|
||||
flowCell,
|
||||
loadedEditorPath,
|
||||
deployedRevision,
|
||||
logTabUsage,
|
||||
|
||||
async loadFlow(workspace: string, path: string, force = false) {
|
||||
const { slot, store, stateStore, saved } = flowCell(path)
|
||||
@@ -921,7 +951,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
}
|
||||
},
|
||||
|
||||
syncPreviewWithDeployed(workspace, kind, path) {
|
||||
itemDeployed(workspace, kind, path) {
|
||||
// After deploy the editor state equals the deployed value; the reload
|
||||
// below re-seeds the cell from it, which must NOT POST as a fresh draft.
|
||||
// The full-page editor guards this with discardDraftAfterDeploy, but the
|
||||
@@ -930,10 +960,20 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// bracket. Covers all three kinds since they all funnel through this.
|
||||
UserDraft.stopSync(kind, path, { workspace })
|
||||
UserDraft.discard(kind, path, undefined, { workspace })
|
||||
// The single funnel for editor and chat deploys alike, so one bump here
|
||||
// covers every way this item's deployed version can change from inside.
|
||||
deployedRevisions[revisionKey(kind, path)] = deployedRevision(kind, path) + 1
|
||||
if (kind === 'script') void this.loadScript(workspace, path, true)
|
||||
else if (kind === 'flow') void this.loadFlow(workspace, path, true)
|
||||
else void this.loadRawApp(workspace, path, true)
|
||||
armRestartOnFirstInteraction(workspace, kind, path)
|
||||
// The deploy cleared the item's pending draft and changed what is deployed at
|
||||
// its path, so the two workspace-wide caches that answer those questions are
|
||||
// now wrong: the Draft Count, and the picker rows whose `draftOnly` decides
|
||||
// which side a pick opens. The picker speaks `app` where a session target says
|
||||
// `raw_app`; translating here keeps that spelling out of every deploy site.
|
||||
invalidateWorkspaceDrafts(workspace)
|
||||
invalidateWorkspaceItems(workspace, kind === 'raw_app' ? 'app' : kind)
|
||||
},
|
||||
|
||||
registerRuntimeLogRequester(appPath, requester) {
|
||||
@@ -1190,7 +1230,7 @@ export function removeSession(sessionId: string): void {
|
||||
// backgrounded session's tool call opens its OWN preview, not the one the user
|
||||
// happens to be viewing. Outside a session there is no calling/active id and
|
||||
// the tool returns a polite error.
|
||||
setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
|
||||
setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) => {
|
||||
const sessionId = callerSessionId ?? sessionState.currentSessionId
|
||||
if (!sessionId) {
|
||||
return 'Error: no active session to open the preview in.'
|
||||
@@ -1204,7 +1244,11 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
|
||||
return `Error: ${kind} targets cannot be shown in the preview panel.`
|
||||
}
|
||||
const runtime = getOrCreateRuntime(session)
|
||||
const result = runtime.previewTabs.open(target)
|
||||
// The editor unless the model asked otherwise: this tool's job is showing the user
|
||||
// the draft a write tool just produced. A reference to an item that already exists
|
||||
// reaches the panel as a pill in the transcript, which asks for the deployed page.
|
||||
const resolved = target.type === 'item' ? { ...target, mode: mode ?? 'edit' } : target
|
||||
const result = runtime.previewTabs.open(resolved)
|
||||
// The pipeline editor registers its build_pipeline_node / edit_pipeline_node
|
||||
// tools asynchronously once the canvas mounts. Block the tool result until
|
||||
// they are live so the model's next turn doesn't race ahead and hit an
|
||||
@@ -1305,9 +1349,14 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
|
||||
const runtime = runtimes.get(sessionId)
|
||||
if (!session?.workspace_id || !runtime) return
|
||||
// Peek without creating a cell: a deploy for an item with no open editor tab
|
||||
// must not allocate an empty cell that lingers until the next prune.
|
||||
if (runtime.loadedEditorPath(kind, path) !== path) return
|
||||
runtime.syncPreviewWithDeployed(session.workspace_id, kind, path)
|
||||
// must not allocate an empty cell that lingers until the next prune. The caches
|
||||
// still answer for the item, so they are dropped either way.
|
||||
if (runtime.loadedEditorPath(kind, path) !== path) {
|
||||
invalidateWorkspaceDrafts(session.workspace_id)
|
||||
invalidateWorkspaceItems(session.workspace_id, kind === 'raw_app' ? 'app' : kind)
|
||||
return
|
||||
}
|
||||
runtime.itemDeployed(session.workspace_id, kind, path)
|
||||
})
|
||||
|
||||
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) => {
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('takeNewSessionSeed', () => {
|
||||
rememberNavRoute('/flows/edit/u/me/my_flow?workspace=ws')
|
||||
expect(takeNewSessionSeed()).toEqual({
|
||||
url: '/flows/edit/u/me/my_flow?workspace=ws',
|
||||
route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow' }
|
||||
route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow', mode: 'edit' }
|
||||
})
|
||||
// A dismissed or taken offer is not repeated until the user leaves again.
|
||||
expect(takeNewSessionSeed()).toBeUndefined()
|
||||
@@ -267,7 +267,8 @@ describe('takeNewSessionSeed', () => {
|
||||
expect(takeNewSessionSeed()?.route).toEqual({
|
||||
kind: 'app',
|
||||
raw_app: true,
|
||||
itemPath: 'f/team/dashboard'
|
||||
itemPath: 'f/team/dashboard',
|
||||
mode: 'edit'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ function itemsEqual(a: WorkspaceItem[] | undefined, b: WorkspaceItem[]): boolean
|
||||
item.summary === b[i].summary &&
|
||||
item.kind === b[i].kind &&
|
||||
item.raw_app === b[i].raw_app &&
|
||||
item.draftPath === b[i].draftPath
|
||||
item.draftPath === b[i].draftPath &&
|
||||
item.draftOnly === b[i].draftOnly &&
|
||||
item.hasDraft === b[i].hasDraft
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Flow, ListableApp, Script } from '$lib/gen'
|
||||
|
||||
export type WorkspaceItemKind = 'flow' | 'script' | 'app'
|
||||
|
||||
export type WorkspaceItem = {
|
||||
@@ -12,6 +10,15 @@ export type WorkspaceItem = {
|
||||
* Display-only: `path` stays the storage key and is what URLs and
|
||||
* dedupe must use — routing to the friendly path 404s. */
|
||||
draftPath?: string
|
||||
/** Nothing is deployed at this path: the row exists only because the
|
||||
* listers were asked for `include_draft_only`. Decides which side of an
|
||||
* item a picker opens, so it must be invalidated on deploy — a stale
|
||||
* `true` sends the reader to the editor of something they just shipped. */
|
||||
draftOnly?: boolean
|
||||
/** The authed user has a draft for this item. True for a draft-only item too,
|
||||
* so read it with `draftOnly` to tell "nothing deployed" from "deployed, with
|
||||
* edits pending". */
|
||||
hasDraft?: boolean
|
||||
}
|
||||
|
||||
/** Path to display (and group by) in pickers: the friendly draft path when
|
||||
@@ -48,6 +55,13 @@ export function editPathFor(item: WorkspaceItem): string {
|
||||
return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}`
|
||||
}
|
||||
|
||||
/** The deployed-version page for an item, the `/get/` counterpart of {@link editPathFor}. */
|
||||
export function viewPathFor(item: WorkspaceItem): string {
|
||||
if (item.kind === 'flow') return `/flows/get/${item.path}`
|
||||
if (item.kind === 'script') return `/scripts/get/${item.path}`
|
||||
return item.raw_app ? `/apps_raw/get/${item.path}` : `/apps/get/${item.path}`
|
||||
}
|
||||
|
||||
type WorkspaceCache = {
|
||||
flow?: WorkspaceItem[]
|
||||
script?: WorkspaceItem[]
|
||||
@@ -120,11 +134,13 @@ export async function loadKind(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = flows.map((f: Flow & { draft_path?: string }) => ({
|
||||
items = flows.map((f) => ({
|
||||
path: f.path,
|
||||
summary: f.summary ?? '',
|
||||
kind: 'flow' as const,
|
||||
draftPath: f.draft_path
|
||||
draftPath: f.draft_path,
|
||||
draftOnly: f.draft_only ?? false,
|
||||
hasDraft: f.is_draft ?? false
|
||||
}))
|
||||
} else if (kind === 'script') {
|
||||
const scripts = await ScriptService.listScripts({
|
||||
@@ -132,23 +148,27 @@ export async function loadKind(
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = scripts.map((s: Script & { draft_path?: string }) => ({
|
||||
items = scripts.map((s) => ({
|
||||
path: s.path,
|
||||
summary: s.summary ?? '',
|
||||
kind: 'script' as const,
|
||||
draftPath: s.draft_path
|
||||
draftPath: s.draft_path,
|
||||
draftOnly: s.draft_only ?? false,
|
||||
hasDraft: s.is_draft ?? false
|
||||
}))
|
||||
} else {
|
||||
const apps = await AppService.listApps({
|
||||
workspace,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
items = apps.map((a: ListableApp & { draft_path?: string }) => ({
|
||||
items = apps.map((a) => ({
|
||||
path: a.path,
|
||||
summary: a.summary ?? '',
|
||||
kind: 'app' as const,
|
||||
raw_app: a.raw_app ?? false,
|
||||
draftPath: a.draft_path
|
||||
draftPath: a.draft_path,
|
||||
draftOnly: a.draft_only ?? false,
|
||||
hasDraft: a.is_draft ?? false
|
||||
}))
|
||||
}
|
||||
// Only commit if the cache version hasn't changed since we started —
|
||||
|
||||
@@ -364,6 +364,8 @@ describe('cleanValueProperties', () => {
|
||||
'other_drafts_users',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'edited_at',
|
||||
'edited_by',
|
||||
'workspace_id',
|
||||
'parent_hashes',
|
||||
'lock_error_logs'
|
||||
|
||||
@@ -1461,6 +1461,8 @@ const CLEANED_VALUE_KEYS = new Set([
|
||||
'other_drafts_users',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'edited_at',
|
||||
'edited_by',
|
||||
'workspace_id',
|
||||
'parent_hashes',
|
||||
'lock_error_logs'
|
||||
|
||||
@@ -81,17 +81,23 @@ function editPathFor(itemType: ItemType, itemPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildForkEditUrl(itemType: ItemType, itemPath: string): string {
|
||||
export function buildForkEditUrl(
|
||||
itemType: ItemType,
|
||||
itemPath: string,
|
||||
prodWorkspace?: string
|
||||
): string {
|
||||
// When the current ("prod") workspace has a canonical dev workspace, edits are funneled there.
|
||||
const dev = findCanonicalDevWorkspace(get(workspaceStore), get(userWorkspaces))
|
||||
const dev = findCanonicalDevWorkspace(prodWorkspace ?? get(workspaceStore), get(userWorkspaces))
|
||||
return dev
|
||||
? devWorkspaceEditUrl(itemType, itemPath, dev.id)
|
||||
: forkWorkspaceUrl(itemType, itemPath)
|
||||
: forkWorkspaceUrl(itemType, itemPath, prodWorkspace)
|
||||
}
|
||||
|
||||
/** Fork-creation flow, coming back to the item's editor once the fork exists. */
|
||||
export function forkWorkspaceUrl(itemType: ItemType, itemPath: string): string {
|
||||
return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}`
|
||||
/** Fork-creation flow, coming back to the item's editor once the fork exists. It forks the
|
||||
* workspace the page loads in, so a caller acting on another one names it. */
|
||||
export function forkWorkspaceUrl(itemType: ItemType, itemPath: string, workspace?: string): string {
|
||||
const from = workspace ? `&workspace=${encodeURIComponent(workspace)}` : ''
|
||||
return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}${from}`
|
||||
}
|
||||
|
||||
/** The item's editor in the dev workspace — the target `buildForkEditUrl` produces when a dev exists. */
|
||||
@@ -167,7 +173,7 @@ export async function onEditInForkClick(
|
||||
e: Event | undefined,
|
||||
itemType: ItemType,
|
||||
itemPath: string,
|
||||
{ hasHref = false }: { hasHref?: boolean } = {}
|
||||
{ hasHref = false, prodWorkspace }: { hasHref?: boolean; prodWorkspace?: string } = {}
|
||||
): Promise<void> {
|
||||
const click = e as MouseEvent | undefined
|
||||
if (
|
||||
@@ -175,10 +181,10 @@ export async function onEditInForkClick(
|
||||
(click?.ctrlKey || click?.metaKey || click?.shiftKey || click?.altKey || click?.button)
|
||||
)
|
||||
return
|
||||
const target = currentDevWorkspace()
|
||||
const target = currentDevWorkspace(prodWorkspace)
|
||||
if (!target) {
|
||||
// Nothing to probe: the destination is the fork-creation flow, which the anchor already points at.
|
||||
if (!hasHref) await goto(forkWorkspaceUrl(itemType, itemPath))
|
||||
if (!hasHref) await goto(forkWorkspaceUrl(itemType, itemPath, prodWorkspace))
|
||||
return
|
||||
}
|
||||
e?.preventDefault()
|
||||
@@ -221,7 +227,7 @@ export async function openEditInFork(
|
||||
const target = currentDevWorkspace(prodWorkspace)
|
||||
if (!target) {
|
||||
// No dev workspace to probe for: the destination is the fork-creation flow.
|
||||
if (!window.open(forkWorkspaceUrl(itemType, itemPath))) {
|
||||
if (!window.open(forkWorkspaceUrl(itemType, itemPath, prodWorkspace))) {
|
||||
sendUserToast('Allow popups to fork this workspace', true)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
import {
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelTop,
|
||||
PanelLeftDashed,
|
||||
PanelLeftOpen,
|
||||
Home,
|
||||
@@ -87,7 +86,6 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
|
||||
import { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
|
||||
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
|
||||
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
@@ -455,10 +453,6 @@
|
||||
} else {
|
||||
menuOpen = false
|
||||
}
|
||||
// The next page may not be one the band floats over, and then the band loses the hover
|
||||
// handlers that would clear this — leaving it already down when a full-bleed page comes
|
||||
// back. Clicking a control in the band (an app's Edit) is exactly that navigation.
|
||||
bandPeeked = false
|
||||
|
||||
// Inside a sessions-preview iframe, hand an editor-route navigation up to the
|
||||
// parent so it mounts the in-process editor (sharing the session runtime)
|
||||
@@ -534,46 +528,9 @@
|
||||
menuOpen = false
|
||||
}, PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
// A deployed app owns the viewport: the band floats above it until the corner handle calls it
|
||||
// down, and stays once pinned. The sidebar is untouched — docked stays docked, detached stays a
|
||||
// peek — since the pin is about the header alone.
|
||||
const fullBleed = $derived(pageHeader.content?.fullBleed === true && !menuHidden)
|
||||
const bandPeek = $derived(fullBleed && !appHeaderPinned.val)
|
||||
let bandPeeked = $state(false)
|
||||
let bandHandleHot = $state(false)
|
||||
let bandHandleEl: HTMLDivElement | undefined = $state(undefined)
|
||||
const bandShown = $derived(!bandPeek || bandPeeked)
|
||||
// A page registers on mount, so the band is an ordinary header for the frames before that and
|
||||
// would slide away once the app claims the viewport. The slide belongs to the hover, not to
|
||||
// arriving: it is armed a frame after the claim, so the first hide is a cut.
|
||||
let bandPeekSettled = $state(false)
|
||||
$effect(() => {
|
||||
if (!bandPeek) {
|
||||
bandPeekSettled = false
|
||||
return
|
||||
}
|
||||
const id = requestAnimationFrame(() => (bandPeekSettled = true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
})
|
||||
const floatBand = $derived(pageHeader.content?.barRightInset != null || bandPeek)
|
||||
// Same delay as the sidebar's peek, and for the same reason: the handle and the band do not
|
||||
// touch, so the leave of one has to survive long enough for the enter of the other.
|
||||
// The band carries menus of its own — the workspace disc, the fork family, every path level —
|
||||
// and they portal out of it just as the card's do, so it holds for the same reason.
|
||||
const { debounced: scheduleBandHide, clearDebounce: cancelBandHide } = debounce(() => {
|
||||
if (pointerInMenu()) {
|
||||
scheduleBandHide()
|
||||
return
|
||||
}
|
||||
bandPeeked = false
|
||||
}, PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
// Offered both by the corner handle and by the band it calls down, which covers that handle.
|
||||
function pinAppHeader() {
|
||||
appHeaderPinned.val = true
|
||||
// The band stays because it is pinned now, not because the pointer is still on it.
|
||||
bandPeeked = false
|
||||
}
|
||||
// A page that owns the right edge from the top — a session's side panel — makes the band float
|
||||
// and stop where that column starts, instead of running over it.
|
||||
const floatBand = $derived(pageHeader.content?.barRightInset != null)
|
||||
|
||||
// The handle and the edge band both open the card this layout owns.
|
||||
navHandleSlot.setOpener(() => (menuOpen = true), {
|
||||
@@ -1409,10 +1366,7 @@
|
||||
the viewport: it would otherwise cover the header's toggle, whose hover opened the
|
||||
card, and swallow the click meant to put the sidebar back. -->
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed left-0 right-0 bottom-0 flex z-40',
|
||||
fullBleed ? '' : 'pl-1 pb-2'
|
||||
)}
|
||||
class={classNames('fixed left-0 right-0 bottom-0 flex z-40', 'pl-1 pb-2')}
|
||||
style:top="{navHandleSlot.cardTop}px"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) menuOpen = false
|
||||
@@ -1423,22 +1377,13 @@
|
||||
data-nav-card
|
||||
onmouseenter={() => {
|
||||
cancelPeekClose()
|
||||
// Revealed together by the floating control, so they leave together: the
|
||||
// pointer resting on either one holds both.
|
||||
if (bandPeek) cancelBandHide()
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
schedulePeekClose()
|
||||
if (bandPeek) scheduleBandHide()
|
||||
}}
|
||||
class={classNames(
|
||||
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden',
|
||||
fullBleed ? 'border-r' : 'rounded-lg border',
|
||||
menuOpen
|
||||
? 'translate-x-0'
|
||||
: fullBleed
|
||||
? '-translate-x-full'
|
||||
: '-translate-x-[calc(100%+0.5rem)]'
|
||||
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden rounded-lg border',
|
||||
menuOpen ? 'translate-x-0' : '-translate-x-[calc(100%+0.5rem)]'
|
||||
)}
|
||||
>
|
||||
{@render sidebarColumn(true)}
|
||||
@@ -1574,89 +1519,15 @@
|
||||
up into the gap and the workspace menu losing the trigger it hangs from.
|
||||
In flow, the band spans the content beside the rail. With an inset, a page owns
|
||||
the right edge from the top (a session's side panel): the band floats, stopping
|
||||
where that column starts, and the page pads its own content to clear it. Over a
|
||||
full-bleed page (a deployed app) it floats too, but waits above the viewport
|
||||
until the corner handle calls it down — and takes no pointer events up there. -->
|
||||
where that column starts, and the page pads its own content to clear it. -->
|
||||
<div
|
||||
class={classNames(
|
||||
floatBand ? 'absolute top-0 z-30' : 'shrink-0',
|
||||
bandPeek
|
||||
? (bandPeekSettled ? 'transition-transform duration-150 ease-out ' : '') +
|
||||
(bandShown ? 'translate-y-0' : '-translate-y-full pointer-events-none')
|
||||
: bandTransitionClass
|
||||
)}
|
||||
class={classNames(floatBand ? 'absolute top-0 z-30' : 'shrink-0', bandTransitionClass)}
|
||||
style:left={floatBand ? `${leftInset}rem` : undefined}
|
||||
style:right={floatBand ? `${rightInset ?? 0}px` : undefined}
|
||||
style:padding-left={floatBand ? undefined : `${leftInset}rem`}
|
||||
onmouseenter={bandPeek
|
||||
? () => {
|
||||
cancelBandHide()
|
||||
// The band comes to rest over the access point, so the pointer leaves it the
|
||||
// moment the band arrives. Hold both open while the pointer is anywhere on
|
||||
// the band, or the card would retract under a header that stayed.
|
||||
if (navDetached.val) navHandleSlot.open(bandHandleEl)
|
||||
}
|
||||
: undefined}
|
||||
onmouseleave={bandPeek
|
||||
? () => {
|
||||
scheduleBandHide()
|
||||
if (navDetached.val) navHandleSlot.scheduleClose()
|
||||
}
|
||||
: undefined}
|
||||
role={bandPeek ? 'presentation' : undefined}
|
||||
>
|
||||
<PageHeaderBar
|
||||
navHidden={menuHidden}
|
||||
hideNavHandle={bandPeek}
|
||||
panelled={useDrawer}
|
||||
onPin={bandPeek ? pinAppHeader : undefined}
|
||||
onUnpin={fullBleed && appHeaderPinned.val
|
||||
? () => (appHeaderPinned.val = false)
|
||||
: undefined}
|
||||
/>
|
||||
<PageHeaderBar navHidden={menuHidden} panelled={useDrawer} />
|
||||
</div>
|
||||
|
||||
{#if bandPeek}
|
||||
<!-- The way back to the chrome from an app that owns the viewport: an access point in
|
||||
the page's top-left corner. Hovering calls the band down, which comes to rest
|
||||
over this button — below the band's z-index, so the regular view simply covers
|
||||
it — and a click pins that view. Faded once the page has settled, so it costs
|
||||
an app's own corner as little as an always-present control can. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-2 z-20 transition-opacity duration-300 {bandShown || bandHandleHot
|
||||
? 'opacity-100'
|
||||
: 'opacity-40'}"
|
||||
style:left="calc({leftInset}rem + 0.5rem)"
|
||||
bind:this={bandHandleEl}
|
||||
onmouseenter={() => {
|
||||
bandHandleHot = true
|
||||
cancelBandHide()
|
||||
bandPeeked = true
|
||||
// The one control for the chrome this page is hiding: the band always, and
|
||||
// the sidebar too when it is detached — hung from this button, since the
|
||||
// header's own handle is off-screen with the band.
|
||||
if (navDetached.val) navHandleSlot.open(bandHandleEl)
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
bandHandleHot = false
|
||||
scheduleBandHide()
|
||||
navHandleSlot.scheduleClose()
|
||||
}}
|
||||
>
|
||||
<!-- Standing where the band's own first button will be — same 28px box, same offsets
|
||||
as its pl-2 and 44px row — so pinning moves nothing. A border would make it
|
||||
30 and the icon would jump on the click. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded bg-surface/80 backdrop-blur-sm shadow-sm hover:bg-surface"
|
||||
aria-label="Pin the header on deployed apps"
|
||||
title="Pin the header on deployed apps"
|
||||
onclick={pinAppHeader}
|
||||
>
|
||||
<PanelTop size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $enterpriseLicense && !menuHidden}
|
||||
<!-- Announcements are an EE feature, so the component never mounts on CE. Also
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
|
||||
<main class="h-full w-full flex flex-col">
|
||||
<DetailPageHeader
|
||||
ownsPageHeader
|
||||
summary={config ? agent?.state?.description : undefined}
|
||||
{path}
|
||||
{menuItems}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* here — links to an app point at this route whatever its kind; the sibling
|
||||
* /apps_raw/get route wraps the same component.
|
||||
*/
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -19,10 +18,6 @@
|
||||
<!-- Wait for the active workspace before mounting the embedder: it's needed to
|
||||
mint the token and to build the viewer iframe URL, and the store is set
|
||||
asynchronously by the (logged) layout. -->
|
||||
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
|
||||
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
|
||||
<PageHeaderContent fullBleed />
|
||||
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation (e.g. a navbar item linking to another app), so the viewer
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* apps get the identical sandbox behavior. PublicAppFrame renders raw apps
|
||||
* inline with the bundle isolated in RawAppPreview's own opaque iframe.
|
||||
*/
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -15,10 +14,6 @@
|
||||
let path = $derived(page.params.path ?? '')
|
||||
</script>
|
||||
|
||||
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
|
||||
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
|
||||
<PageHeaderContent fullBleed />
|
||||
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation, so the viewer must fully remount (see /apps/get). -->
|
||||
|
||||
@@ -96,9 +96,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Discarding the draft after a deploy clears `draftSync.draft` before the
|
||||
// navigation away lands; without this the builder renders an empty flow
|
||||
// for those frames.
|
||||
let flowAtDeploy: Flow | undefined = $state.raw(undefined)
|
||||
|
||||
export const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return draftSync.draft ?? emptyFlow()
|
||||
return draftSync.draft ?? flowAtDeploy ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
draftSync.draft = v
|
||||
@@ -568,6 +573,7 @@
|
||||
onDeploy={(e) => {
|
||||
// stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed.
|
||||
if ($workspaceStore) {
|
||||
flowAtDeploy = flowStore.val
|
||||
discardDraftAfterDeploy({
|
||||
workspace: $workspaceStore,
|
||||
itemKind: 'flow',
|
||||
|
||||
@@ -1,907 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type TriggersCount,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
defaultIfEmptyString,
|
||||
emptyString,
|
||||
urlParamsToObject,
|
||||
extractTagFromSharableHash,
|
||||
interpolateTag,
|
||||
isDynamicTag,
|
||||
isTagTemplate
|
||||
} from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
|
||||
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
|
||||
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
import {
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Trash,
|
||||
ChevronUpSquare,
|
||||
Shield,
|
||||
Loader2,
|
||||
GitFork,
|
||||
Play,
|
||||
History,
|
||||
Pen,
|
||||
Eye,
|
||||
HistoryIcon,
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { setContext } from 'svelte'
|
||||
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import { page } from '$app/state'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkDescription,
|
||||
editInForkLabel,
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import FlowDetail from '$lib/components/details/FlowDetail.svelte'
|
||||
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
let can_write = $state(false)
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
|
||||
let scheduledForStr: string | undefined = $state(undefined)
|
||||
let invisible_to_owner: boolean | undefined = $state(undefined)
|
||||
let overrideTag: string | undefined = $state(undefined)
|
||||
let overrideTagNote: string | undefined = $state(undefined)
|
||||
// Tag carried over from 'Run again', pending the dynamic-tag check in loadFlow
|
||||
let carriedTag: string | undefined = undefined
|
||||
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
|
||||
let jsonView = $state(false)
|
||||
let deploymentInProgress = $state(false)
|
||||
let deploymentJobId: string | undefined = $state(undefined)
|
||||
|
||||
let intervalId: number | undefined = undefined
|
||||
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
{ type: 'cli', path: '', isDraft: false }
|
||||
])
|
||||
)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState
|
||||
})
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flow?.value.modules ?? [] })
|
||||
)
|
||||
|
||||
// `${path}|${version}` so navigating between pinned and latest re-runs loadFlow.
|
||||
let previousLoadKey: string | undefined = $state(undefined)
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function deleteFlow(): Promise<void> {
|
||||
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
|
||||
sendUserToast('Flow deleted')
|
||||
goto('/')
|
||||
}
|
||||
|
||||
async function loadTriggersCount() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
await triggersState.fetchTriggers(
|
||||
triggersCount,
|
||||
$workspaceStore,
|
||||
path,
|
||||
true,
|
||||
undefined,
|
||||
$userStore
|
||||
)
|
||||
}
|
||||
|
||||
let pinnedVersion: number | undefined = $state(undefined)
|
||||
|
||||
async function loadFlow(): Promise<void> {
|
||||
const versionParam = page.url.searchParams.get('version')
|
||||
const versionId = versionParam ? Number(versionParam) : NaN
|
||||
if (Number.isFinite(versionId)) {
|
||||
const versioned = await FlowService.getFlowVersion({
|
||||
workspace: $workspaceStore!,
|
||||
version: versionId
|
||||
})
|
||||
if (versioned.path !== path) {
|
||||
sendUserToast(`Flow version ${versionId} belongs to ${versioned.path}, not ${path}.`, true)
|
||||
goto(`/flows/get/${versioned.path}?workspace=${$workspaceStore}&version=${versionId}`)
|
||||
return
|
||||
}
|
||||
flow = versioned
|
||||
pinnedVersion = versionId
|
||||
} else {
|
||||
flow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
withStarredInfo: true
|
||||
})
|
||||
pinnedVersion = undefined
|
||||
}
|
||||
// A carried non-template value equal to the resolution of the flow's own dynamic
|
||||
// tag for these args is not an override but the previous run's pinned resolution:
|
||||
// drop it so the backend re-resolves from the (possibly edited) args. A differing
|
||||
// value is a genuine user override and a template re-resolves at push, so both stay.
|
||||
if (
|
||||
carriedTag &&
|
||||
isDynamicTag(flow.tag) &&
|
||||
!isTagTemplate(carriedTag) &&
|
||||
interpolateTag(flow.tag ?? '', $workspaceStore!, args) === carriedTag
|
||||
) {
|
||||
if (overrideTag === carriedTag) {
|
||||
overrideTag = undefined
|
||||
overrideTagNote = `tag ${flow.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
|
||||
}
|
||||
carriedTag = undefined
|
||||
}
|
||||
if (!flow.path.startsWith(`u/${$userStore?.username}`) && flow.path.split('/').length > 2) {
|
||||
invisible_to_owner = flow.visible_to_runner_only
|
||||
}
|
||||
intervalId && clearInterval(intervalId)
|
||||
deploymentInProgress = flow.lock_error_logs == ''
|
||||
if (deploymentInProgress) {
|
||||
intervalId = setInterval(syncer, 500)
|
||||
}
|
||||
can_write = canWrite(flow.path, flow.extra_perms!, $userStore)
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let loading = $state(false)
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (flow) {
|
||||
const status = await FlowService.getFlowDeploymentStatus({
|
||||
workspace: $workspaceStore!,
|
||||
path: flow.path
|
||||
})
|
||||
if (status.lock_error_logs == undefined || status.lock_error_logs != '') {
|
||||
deploymentInProgress = false
|
||||
deploymentJobId = undefined
|
||||
flow.lock_error_logs = status.lock_error_logs
|
||||
clearInterval(intervalId)
|
||||
} else if (status.job_id) {
|
||||
deploymentJobId = status.job_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlow(
|
||||
scheduledForStr: string | undefined,
|
||||
args: Record<string, any>,
|
||||
invisibleToOwner: boolean | undefined,
|
||||
overrideTag: string | undefined
|
||||
) {
|
||||
loading = true
|
||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||
try {
|
||||
let run = await JobService.runFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
invisibleToOwner,
|
||||
requestBody: args,
|
||||
scheduledFor,
|
||||
tag: overrideTag,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
await goto('/run/' + run + '?workspace=' + $workspaceStore)
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlowForChat(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const run = await JobService.runFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
memoryId: conversationId,
|
||||
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
skipPreprocessor: true
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
let args: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
let hash = window.location.hash
|
||||
if (hash.length > 1) {
|
||||
try {
|
||||
let searchParams = new URLSearchParams(hash.slice(1))
|
||||
carriedTag = extractTagFromSharableHash(searchParams)
|
||||
overrideTag = carriedTag
|
||||
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
|
||||
k,
|
||||
JSON.parse(v)
|
||||
])
|
||||
args = Object.fromEntries(params)
|
||||
} catch (e) {
|
||||
console.error('Was not able to transform hash as args', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Operators with the builder right author flows out of deployed runnables; every other
|
||||
// operator is read-only here.
|
||||
let canAuthorFlow = $derived(!$userStore?.operator || $operatorBuilderFlows)
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let runForm: RunForm | undefined = $state()
|
||||
|
||||
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
|
||||
const buttons: any = []
|
||||
|
||||
if (flow && canAuthorFlow) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: `${base}/flows/add?template=${flow.path}`,
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
flow &&
|
||||
!$userStore?.operator &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
description: editInForkDescription('flow', $workspaceStore, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path),
|
||||
onClick: (e: Event | undefined) =>
|
||||
onEditInForkClick(e, 'flow', flow.path, { hasHref: true }),
|
||||
unifiedSize: 'md',
|
||||
variant: !showEditButtons ? 'default' : 'subtle',
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: `Runs`,
|
||||
buttonProps: {
|
||||
href: `${base}/runs/${flow.path}`,
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: Play
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: History
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || !canAuthorFlow || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
// The builder right covers flows only; building an app is still refused to operators.
|
||||
if (!$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// The raw app editor reads the payload from sessionStorage on mount.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await goto('/apps_raw/add')
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
href: `${base}/flows/edit/${path}`,
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
return buttons
|
||||
}
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
function getMenuItems(
|
||||
flow: Flow | undefined,
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
) {
|
||||
if (!flow || !canAuthorFlow) return []
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Permissions',
|
||||
onclick: () => shareModal?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: Shield,
|
||||
disabled: !can_write
|
||||
})
|
||||
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
|
||||
Icon: FolderOpen
|
||||
})
|
||||
}
|
||||
|
||||
// The builder right opens this menu to operators; audit logs stay behind their own setting.
|
||||
if (
|
||||
!$userStore?.operator ||
|
||||
$userWorkspaces.find((w) => w.id === $workspaceStore)?.operator_settings?.audit_logs
|
||||
) {
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
Icon: Eye,
|
||||
onclick: () => {
|
||||
goto(`/audit_logs?resource=${flow?.path}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !$userStore?.operator) {
|
||||
menuItems.push({
|
||||
label: 'Deploy to staging/prod',
|
||||
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: ChevronUpSquare
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
Icon: HistoryIcon
|
||||
})
|
||||
menuItems.push({
|
||||
label: flow.archived ? 'Unarchive' : 'Archive',
|
||||
onclick: () => flow?.path && archiveFlow(),
|
||||
Icon: Archive,
|
||||
color: 'red'
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
onclick: () => flow?.path && deleteFlow(),
|
||||
Icon: Trash,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
return menuItems
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
intervalId && clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm?.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let detailLayout: DetailPageLayout | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(page.params.path ?? '')
|
||||
|
||||
let topSectionHeight = $state(0)
|
||||
let paneHeight = $state(0)
|
||||
let flowGraphHeight = $state(0)
|
||||
|
||||
let graphMinHeight = $derived.by(() => {
|
||||
if (!topSectionHeight || !paneHeight) return 400
|
||||
const availableHeight = paneHeight - topSectionHeight - 1 // Account for the separator between the top section and the graph
|
||||
return Math.max(400, availableHeight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
|
||||
if (cliTrigger) {
|
||||
cliTrigger.extra = {
|
||||
cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore && page.params.path) {
|
||||
const versionParam = page.url.searchParams.get('version') ?? ''
|
||||
const loadKey = `${$workspaceStore}|${path}|${versionParam}`
|
||||
if (previousLoadKey !== loadKey) {
|
||||
previousLoadKey = loadKey
|
||||
untrack(() => {
|
||||
loadFlow()
|
||||
loadTriggersCount()
|
||||
loadTriggers()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
// Read once, as the fragment is only ever set by the link that opened this page
|
||||
// (`Run again`, which prefills the run form from it) and never updated in place.
|
||||
const locationHash = window.location.hash
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await goto('/flows/get/' + e.detail + `?workspace=${$workspaceStore}`)
|
||||
loadFlow()
|
||||
}}
|
||||
/>
|
||||
{#if flow}
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:this={detailLayout}
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={$userStore?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
isChatMode={chatInputEnabled}
|
||||
flow_json={{
|
||||
value: flow?.value,
|
||||
summary: flow?.summary,
|
||||
description: flow?.description,
|
||||
schema: flow?.schema
|
||||
}}
|
||||
>
|
||||
{#snippet header()}
|
||||
<DetailPageHeader
|
||||
on:seeTriggers={() => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
{mainButtons}
|
||||
menuItems={getMenuItems(flow, deployUiSettings)}
|
||||
bind:errorHandlerMuted={
|
||||
() => flow?.ws_error_handler_muted ?? false,
|
||||
(v) => {
|
||||
if (flow !== undefined) flow.ws_error_handler_muted = v
|
||||
}
|
||||
}
|
||||
scriptOrFlowPath={flow?.path ?? ''}
|
||||
errorHandlerKind="flow"
|
||||
tag={flow?.tag ?? ''}
|
||||
labels={flow?.labels}
|
||||
inheritedLabels={flow?.inherited_labels}
|
||||
summary={flow?.summary}
|
||||
path={flow?.path}
|
||||
onSaved={can_write
|
||||
? async (newPath) => {
|
||||
if (newPath !== flow?.path) {
|
||||
await goto(`/flows/get/${newPath}?workspace=${$workspaceStore}`)
|
||||
} else {
|
||||
loadFlow()
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
showDraft={false}
|
||||
{path}
|
||||
newItem={false}
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
detailLayout?.showTriggers()
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if $workspaceStore && flow}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
kind="flow"
|
||||
/>
|
||||
{#if flow?.value?.priority != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="blue" variant="outlined" size="xs">
|
||||
{`Priority: ${flow?.value?.priority}`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="gray" variant="outlined" size="xs">
|
||||
{`Concurrency limit: ${flow?.value?.concurrent_limit} runs every ${flow?.value?.concurrency_time_window_s}s`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form({ graphInline }: { graphInline: boolean })}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
{#if flow}
|
||||
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
|
||||
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
<!-- The chat reaches the edges of the pane, so the notices above it carry their
|
||||
own padding. `contents` leaves the form layout exactly as it was. -->
|
||||
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
|
||||
comment anchor behind, so an empty wrapper is not `:empty` and its own
|
||||
padding would show as a gap above a chat with nothing to announce. -->
|
||||
<div
|
||||
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<!-- In chat mode the description belongs to the chat, which shows it under the
|
||||
empty transcript. -->
|
||||
{#if !chatInputEnabled && !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
<FlowChat
|
||||
onRunFlow={runFlowForChat}
|
||||
{deploymentInProgress}
|
||||
path={flow?.path ?? ''}
|
||||
description={flow?.description}
|
||||
inputSchema={flow?.schema}
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
<!-- Normal Mode: Form Layout -->
|
||||
<div class="flex flex-col align-left">
|
||||
{#if hasSchema || inputSelected}
|
||||
<div
|
||||
class="flex flex-row justify-between min-h-12"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<InputSelectedBadge
|
||||
onReject={() => {
|
||||
savedInputsV2?.resetSelected()
|
||||
}}
|
||||
{inputSelected}
|
||||
/>
|
||||
|
||||
{#if hasSchema}
|
||||
<Toggle
|
||||
bind:checked={jsonView}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: 'Fill args from JSON'
|
||||
}}
|
||||
lightMode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.schema?.prompt_for_ai !== undefined}
|
||||
<AIFormAssistant
|
||||
instructions={flow.schema?.prompt_for_ai as string}
|
||||
onEditInstructions={() => {
|
||||
goto(`/flows/edit/${flow?.path}`)
|
||||
}}
|
||||
runnableType="flow"
|
||||
path={flow?.path}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
{overrideTagNote}
|
||||
viewKeybinding
|
||||
{loading}
|
||||
autofocus
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
bind:args
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex flex-col gap-1 w-full items-end">
|
||||
<span class="text-2xs text-secondary">
|
||||
Edited <TimeAgo date={flow.edited_at ?? ''} noSeconds /> by {flow.edited_by}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if graphInline}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
minHeight={graphMinHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet save_inputs()}
|
||||
<SavedInputsV2
|
||||
bind:this={savedInputsV2}
|
||||
schema={flow?.schema}
|
||||
{jsonView}
|
||||
flowPath={flow?.path}
|
||||
{isValid}
|
||||
args={args ?? {}}
|
||||
bind:inputSelected
|
||||
on:selected_args={(e) => {
|
||||
const nargs = JSON.parse(JSON.stringify(e.detail))
|
||||
args = nargs
|
||||
if (jsonView) {
|
||||
runForm?.syncJsonEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step({ onBack }: { onBack?: () => void })}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {onBack} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{#if flow}
|
||||
<TriggersEditor
|
||||
{args}
|
||||
runnableVersion={flow.version_id?.toString()}
|
||||
initialPath={flow.path}
|
||||
currentPath={flow.path}
|
||||
noEditor={true}
|
||||
newItem={false}
|
||||
isFlow={true}
|
||||
schema={flow.schema}
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_graph()}
|
||||
{#if flow}
|
||||
<div class="h-full overflow-auto" bind:clientHeight={flowGraphHeight}>
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
detailLayout?.showTriggers()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageLayout>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flow?.value.modules ?? []}
|
||||
enableDbExplore
|
||||
enablePathScriptAndFlowAssets
|
||||
<FlowDetail
|
||||
path={page.params.path ?? ''}
|
||||
workspace={$workspaceStore}
|
||||
searchParams={page.url.searchParams}
|
||||
{locationHash}
|
||||
onNavigate={goto}
|
||||
active={true}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,10 +68,15 @@
|
||||
type PageItemRef,
|
||||
parsePreviewItemRoute,
|
||||
previewLocationLabel,
|
||||
resolvePreviewTab,
|
||||
workspacePageHref,
|
||||
type PreviewTarget
|
||||
} from '$lib/components/sessions/previewRouter'
|
||||
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
|
||||
import {
|
||||
toolReloadEffect,
|
||||
tabsToReload,
|
||||
viewerTabsToReload
|
||||
} from '$lib/components/sessions/previewReload'
|
||||
import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths'
|
||||
import {
|
||||
leafKeyFor,
|
||||
@@ -428,6 +433,15 @@
|
||||
title: tabTitleFor(t, previewWorkspace ?? '')
|
||||
}))
|
||||
)
|
||||
|
||||
/** Marks the editor side, since both sides of one item can be open at once and would
|
||||
* otherwise be two tabs with the same name. Only the editor is marked: the deployed
|
||||
* page is the item itself, and a page, artifact or pipeline has no second side to be
|
||||
* told apart from. */
|
||||
function sideSuffix(url: string): string {
|
||||
const slot = resolvePreviewTab(url)
|
||||
return slot.kind === 'editor' && slot.editorKind !== 'pipeline' ? ' (edit)' : ''
|
||||
}
|
||||
let newTabOpen = $state(false)
|
||||
// Separate open flag for the empty-state launcher: it can be mounted at the
|
||||
// same time as the tab-strip "+" popover, so sharing one flag would open both
|
||||
@@ -585,18 +599,20 @@
|
||||
|
||||
// Reload mounted preview tabs affected by a mutating chat tool. Item and pipeline
|
||||
// tabs are live editors that self-sync from the store the chat mutates, so nothing
|
||||
// reloads them. List-page tabs (schedules, resources, …) and page item tabs reload
|
||||
// only when a tool actually changed *their* page or item (toolReloadEffect) — so a
|
||||
// schedule write leaves the Resources tab alone, and a purely local tool (saving
|
||||
// user instructions) reloads nothing.
|
||||
// reloads them. Three kinds do need telling, each only when a tool actually changed
|
||||
// what IT shows (toolReloadEffect): list-page tabs (schedules, resources, …), page
|
||||
// item tabs, and tabs on an item's View side, which render the deployed version over
|
||||
// the API. So a schedule write leaves the Resources tab alone, and a purely local
|
||||
// tool (saving user instructions) reloads nothing.
|
||||
const tabHosts: Record<string, PreviewTabHost | undefined> = {}
|
||||
|
||||
let reloadHandle: ReturnType<typeof setTimeout> | undefined
|
||||
// Per workspace a chat round touched since the last flush: base-stripped list-page paths
|
||||
// (e.g. `/schedules`, see toolReloadEffect) and the page items its tools named, as tab
|
||||
// urls. By workspace because a path names an item only within one: a write in one fork
|
||||
// must not remount the same path's editor in a session on another.
|
||||
let pending = new Map<string, { pages: Set<string>; items: Set<string> }>()
|
||||
// (e.g. `/schedules`, see toolReloadEffect), the page items its tools named, as tab urls,
|
||||
// and the item paths whose deployed version changed. By workspace because a path names an
|
||||
// item only within one: a write in one fork must not remount the same path's editor in a
|
||||
// session on another.
|
||||
let pending = new Map<string, { pages: Set<string>; items: Set<string>; deployed: Set<string> }>()
|
||||
|
||||
// Reload the mounted tabs a chat round changed in each warm session acting on that
|
||||
// workspace (a hidden preview would otherwise show pre-mutation content on return).
|
||||
@@ -607,7 +623,11 @@
|
||||
const scope = touched.get(getEffectiveWorkspaceId(s) ?? $workspaceStore ?? '')
|
||||
const owner = getRuntime(s.id)?.previewTabs
|
||||
if (!scope || !owner) continue
|
||||
for (const tab of tabsToReload(owner.tabs, scope.pages, scope.items)) {
|
||||
const affected = [
|
||||
...tabsToReload(owner.tabs, scope.pages, scope.items),
|
||||
...viewerTabsToReload(owner.tabs, scope.deployed)
|
||||
]
|
||||
for (const tab of affected) {
|
||||
const key = tabKey(s.id, tab.id)
|
||||
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
|
||||
}
|
||||
@@ -616,12 +636,17 @@
|
||||
$effect(() => {
|
||||
// Debounced so a burst of writes (the AI editing several files) reloads once.
|
||||
setToolCompletionListener((name, args, workspace) => {
|
||||
const { pages, items } = toolReloadEffect(name, args)
|
||||
if (pages.length === 0) return
|
||||
const { pages, items, deployed } = toolReloadEffect(name, args)
|
||||
if (pages.length === 0 && deployed.length === 0) return
|
||||
let scope = pending.get(workspace)
|
||||
if (!scope) pending.set(workspace, (scope = { pages: new Set(), items: new Set() }))
|
||||
if (!scope)
|
||||
pending.set(
|
||||
workspace,
|
||||
(scope = { pages: new Set(), items: new Set(), deployed: new Set() })
|
||||
)
|
||||
for (const p of pages) scope.pages.add(p)
|
||||
for (const item of items) scope.items.add(pageItemUrl(item))
|
||||
for (const d of deployed) scope.deployed.add(d)
|
||||
clearTimeout(reloadHandle)
|
||||
reloadHandle = setTimeout(flushReload, 500)
|
||||
})
|
||||
@@ -642,7 +667,7 @@
|
||||
if (!o) return
|
||||
const target = previewTargetForSessionTarget(action.previewKind, action.path)
|
||||
if (!target) return
|
||||
o.open(target)
|
||||
o.open(target.type === 'item' ? { ...target, mode: action.mode } : target)
|
||||
})
|
||||
})
|
||||
// Variables, resources, schedules and triggers the chat links to open as tabs of their
|
||||
@@ -721,6 +746,9 @@
|
||||
: undefined
|
||||
)
|
||||
let activeTabPickerOpen = $state(false)
|
||||
/** Measured width of the floating action controls, reserved as the tab strip's right
|
||||
* padding so tabs stop before them instead of scrolling underneath. */
|
||||
let previewActionsWidth = $state(0)
|
||||
|
||||
// Breadcrumb picks steer the *active* tab; the "+" picker opens new ones. An
|
||||
// editable item also becomes the session's live editor (owner.navigate).
|
||||
@@ -780,11 +808,11 @@
|
||||
// for summary-less items and non-item pages.
|
||||
function tabLabelFor(tab: SessionPreviewTab, workspace: string): string {
|
||||
const listed = listedItemFor(tab, workspace)
|
||||
return (
|
||||
const name =
|
||||
tab.friendlyLabel ??
|
||||
(listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ??
|
||||
previewLocationLabel(tab.loc)
|
||||
)
|
||||
return name + sideSuffix(tab.url)
|
||||
}
|
||||
|
||||
// Hover title for a tab. A summary label is free text the strip truncates, and
|
||||
@@ -816,6 +844,15 @@
|
||||
owner?.navigate({ type: 'item', item })
|
||||
}
|
||||
|
||||
// An editor's `Exit & see details`: the session hosts the details page itself, so the
|
||||
// tab moves to the item's deployed view rather than the browser leaving the session.
|
||||
// `open`, not `navigate`: leaving the editor to look at what is deployed is not a
|
||||
// redirect of the tab you are working in — you are stepping away from an editor you
|
||||
// still want, so the deployed page arrives beside it.
|
||||
function seeDetailsInPreview(item: WorkspaceItem) {
|
||||
owner?.open({ type: 'item', item, mode: 'view' })
|
||||
}
|
||||
|
||||
// A preview iframe that navigates to an editor route posts up to us instead of
|
||||
// booting the editor inside the frame (see the logged layout's beforeNavigate).
|
||||
// Retarget the active tab — the navigating frame is the visible one the user
|
||||
@@ -1007,10 +1044,16 @@
|
||||
bind:clientWidth={previewWidth}
|
||||
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}"
|
||||
>
|
||||
<!-- The action controls float over the tab strip, so the strip has to reserve
|
||||
their width or tabs slide underneath them. Measured rather than guessed:
|
||||
the set changes with the tab (an artifact has no "Open in workspace", a
|
||||
page has no View|Edit toggle), so a fixed padding is either short for
|
||||
the widest case or wasted space for the narrowest. -->
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
|
||||
? ''
|
||||
: 'rounded-md border border-light'}"
|
||||
style="--preview-actions-w: {previewActionsWidth + 8}px"
|
||||
>
|
||||
{#if !fullscreen}
|
||||
<!-- Collapse the preview panel — floats over the top-left corner so
|
||||
@@ -1028,7 +1071,14 @@
|
||||
|
||||
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
|
||||
corner to mirror the collapse control. -->
|
||||
<div class="absolute top-1 right-1 z-30 flex items-center gap-0.5">
|
||||
<!-- Spans the tab strip's own height and centres within it, rather than
|
||||
being pinned a fixed distance from the top, so these controls sit on
|
||||
the same axis as the collapse button opposite. Its width is measured
|
||||
so the strip can reserve room and its tabs never scroll underneath. -->
|
||||
<div
|
||||
bind:clientWidth={previewActionsWidth}
|
||||
class="absolute top-0 right-1 z-30 flex h-8 items-center gap-0.5"
|
||||
>
|
||||
{#if activeWorkspaceHref}
|
||||
<a
|
||||
href={withWorkspaceParam(activeWorkspaceHref, previewWorkspace)}
|
||||
@@ -1067,7 +1117,7 @@
|
||||
onReorder={reorderTabs}
|
||||
class="session-preview-tab-strip h-8 border-b border-light bg-surface-secondary/50 {fullscreen
|
||||
? 'pl-1.5'
|
||||
: 'pl-9'} pr-16"
|
||||
: 'pl-9'} pr-[var(--preview-actions-w,4rem)]"
|
||||
>
|
||||
{#snippet tabAccessory(_tab, isActive)}
|
||||
{#if isActive}
|
||||
@@ -1182,6 +1232,7 @@
|
||||
darkMode={isDarkMode.val}
|
||||
{fullscreen}
|
||||
onNavigate={navigateEditorTo}
|
||||
onSeeDetails={seeDetailsInPreview}
|
||||
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user