feat: edit variables, resources and triggers in their own session tab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-17 15:39:22 +02:00
co-authored by Claude Opus 5
parent c297ed0052
commit 9d029c0d44
36 changed files with 1216 additions and 605 deletions
@@ -12,6 +12,7 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -24,6 +25,7 @@
let {
workspace = undefined,
disableChatOffset = false,
inline = false,
onRestored = undefined,
onSaved = undefined
}: {
@@ -31,6 +33,10 @@
* once, at `effectiveWorkspace`, and nowhere else in this file. */
workspace?: string
disableChatOffset?: boolean
/** Render in place, filling the parent, with no drawer or close button — for a host
* that gives the editor a whole pane. Saving and restoring then leave it open: the
* host remounts it on what was written. */
inline?: boolean
onRestored?: () => void
/** Fires after Save has written, for a caller showing state derived from the
* resource — `onRestored` only covers restoring an old version. */
@@ -85,6 +91,7 @@
* dedicated editor elsewhere: the generic form would render its configuration field by field,
* and materialize a default into every one the value leaves out. */
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
if (handOffPageDrawer(RESOURCES_PATH, p)) return
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
// would still be standing when the next drawer session ends and would swallow that one's
// anchor clear. Every session starts having to clear its own.
@@ -126,22 +133,35 @@
)
</script>
<Drawer
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => {
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
}
clearPageDrawerAnchor(RESOURCES_PATH)
}}
>
{#if inline}
<!-- ResourceEditor reads its path once, at mount — a drawer mounts it only when opened. -->
{#if path !== undefined || resource_type !== undefined}
{@render content()}
{/if}
{:else}
<Drawer
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => {
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
}
clearPageDrawerAnchor(RESOURCES_PATH)
}}
>
{@render content()}
</Drawer>
{/if}
{#snippet content()}
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
bannerReserved={mode == 'edit'}
on:close={drawer?.closeDrawer}
hideClose={inline}
fullScreen={!inline}
on:close={() => drawer?.closeDrawer()}
>
{#snippet titleExtra()}
{#if mode == 'new' && resource_type}
@@ -212,7 +232,7 @@
</Button>
{/snippet}
</DrawerContent>
</Drawer>
{/snippet}
<Drawer bind:this={historyDrawer} size="1200px">
<DrawerContent title="Versions History" on:close={historyDrawer?.closeDrawer} noPadding>
@@ -8,6 +8,7 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -38,7 +39,18 @@
// The "current" workspace this editor defaults New/Edit actions to. Session
// editors pass their acting workspace so secrets are created/updated there
// rather than in the navigation workspace.
let { workspace = undefined }: { workspace?: string } = $props()
let {
workspace = undefined,
inline = false,
onSaved = undefined
}: {
workspace?: string
/** Render in place, filling the parent, with no drawer or close button — for a host
* that gives the editor a whole pane. */
inline?: boolean
/** Fires once a save lands, with the path the variable now lives at. */
onSaved?: (path: string) => void
} = $props()
// Sole ambient read in this file: the acting workspace is an input, and only its
// default comes from the navigation store.
let curWs = $derived(workspace ?? $workspaceStore)
@@ -233,6 +245,7 @@
}
export function editVariable(edit_path: string): void {
if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return
reset()
editPath = edit_path
selected = curWs!
@@ -256,6 +269,7 @@
async function save(): Promise<void> {
const dirty = dirtyWorkspaces
const savedPath = current?.path ?? editPath ?? ''
try {
for (const ws of dirty) {
const s = states[ws].draft!
@@ -303,6 +317,7 @@
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
onSaved?.(savedPath)
drawer?.closeDrawer()
} catch (err) {
sendUserToast(`Could not save variable: ${err.body}`, true)
@@ -310,11 +325,21 @@
}
</script>
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
{#if inline}
{@render content()}
{:else}
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
{@render content()}
</Drawer>
{/if}
{#snippet content()}
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
on:close={drawer?.closeDrawer}
hideClose={inline}
fullScreen={!inline}
on:close={() => drawer?.closeDrawer()}
>
{#snippet banner()}
<LocalDraftBanner
@@ -382,4 +407,4 @@
</Button>
{/snippet}
</DrawerContent>
</Drawer>
{/snippet}
@@ -30,6 +30,8 @@
* the content hug it with tight top padding; new entities keep normal padding.
*/
bannerReserved?: boolean
/** For content rendered in place of a drawer, which has nothing to close. */
hideClose?: boolean
children?: import('svelte').Snippet
}
@@ -50,6 +52,7 @@
titleExtra,
banner,
bannerReserved = false,
hideClose = false,
children
}: Props = $props()
@@ -69,19 +72,26 @@
)}
{id}
>
<div class="flex justify-between w-full items-center pl-2 pr-4 py-2 gap-2">
<div
class={classNames(
'flex justify-between w-full items-center pr-4 py-2 gap-2',
hideClose ? 'pl-4' : 'pl-2'
)}
>
<div class="flex items-center gap-2 w-full truncate">
<div
use:triggerableByAI={{
id: `close-${aiId}`,
description: `Close ${aiDescription}`,
callback: () => {
dispatch('close')
}
}}
>
<CloseButton on:close Icon={CloseIcon} id="{id}-close-btn" />
</div>
{#if !hideClose}
<div
use:triggerableByAI={{
id: `close-${aiId}`,
description: `Close ${aiDescription}`,
callback: () => {
dispatch('close')
}
}}
>
<CloseButton on:close Icon={CloseIcon} id="{id}-close-btn" />
</div>
{/if}
<span class="font-semibold text-emphasis truncate text-lg max-w-sm"
>{title ?? ''}
{#if tooltip != '' || documentationLink}
@@ -5,6 +5,10 @@ type MaybePromise<T> = T | Promise<T>
type ToolDisplayActionHandler = (action: ToolDisplayAction) => MaybePromise<void>
const toolDisplayActionHandlers = $state<Record<string, ToolDisplayActionHandler | undefined>>({})
// Every registration per type, latest last: a page that takes over a type from the layout
// (the sessions page opens items in its panel rather than in drawers) hands it back on
// unmount instead of leaving the type unhandled.
const registrations: Record<string, ToolDisplayActionHandler[]> = {}
function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
@@ -17,11 +21,16 @@ export function registerToolDisplayActionHandler(
type: ToolDisplayAction['type'],
handler: ToolDisplayActionHandler
): () => void {
const stack = (registrations[type] ??= [])
stack.push(handler)
toolDisplayActionHandlers[type] = handler
return () => {
if (toolDisplayActionHandlers[type] === handler) {
delete toolDisplayActionHandlers[type]
}
const at = stack.lastIndexOf(handler)
if (at < 0) return
stack.splice(at, 1)
const current = stack[stack.length - 1]
if (current) toolDisplayActionHandlers[type] = current
else delete toolDisplayActionHandlers[type]
}
}
@@ -307,8 +307,8 @@ export type GlobalActivePreviewContext = {
* location: a tab can host a legacy app whose hash is app state, and a filter value
* can be free text the user typed. Build it with `previewLocationContext`. */
location: string
/** The row whose drawer is open on that page. The list pages drop the anchor when
* their drawer closes, so its absence means no row is open. */
/** The item open in its editor: a list page row whose drawer is open, or the item a
* session tab edits. Its absence means no item is open. */
open?: string
}
@@ -1320,7 +1320,7 @@ const buildGlobalSystemPrompt = (
// right now: the system prompt is the cached prefix, so a line appearing and
// disappearing between turns costs more cache than the tool call it saves.
const activePreviewRule = previewTools
? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the row the page is anchored at, whose drawer the user opened) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.'
? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the item the user has open in its editor) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.'
: ''
const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.${pipelineAlphaNote}`
// Hosting and edition come from the hostname and a store the app populates at init, so
@@ -1367,7 +1367,7 @@ ${pipelineBullet}
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged without starting a new test run.
- get_run also covers what a flow run did per step statuses and results across the whole execution tree, subflow steps and loop iterations included and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).
- Use open_page to show a workspace page with filters applied Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
- Whenever you ask the user to perform a manual step in the UI fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting call open_page in the same message, targeted at that item (pass open with its path to land in its edit drawer, or the page's filters otherwise). Never just describe where to click.
- Whenever you ask the user to perform a manual step in the UI fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.
- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" it opens the Compare & Deploy review page.${
previewTools
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
@@ -2772,7 +2772,7 @@ const openPageFullSchema = z.object({
.string()
.optional()
.describe(
'Schedules/Triggers/Variables/Resources: exact item path to open in the edit drawer, e.g. f/foo/my_schedule. Use it whenever the user should act on one specific item (e.g. fill in credentials) so they land directly in its editor.'
'Schedules/Triggers/Variables/Resources: exact item path to open in its editor, e.g. f/foo/my_schedule — in a session, a preview tab of its own instead of the list page. Use it whenever the user should act on one specific item (e.g. fill in credentials) so they land directly in its editor.'
),
summary: z
.string()
@@ -2901,7 +2901,7 @@ function buildOpenPageDefSchema(
}
const OPEN_PAGE_DESCRIPTION =
'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy review page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its edit drawer opens directly). Use page "compare" when the user wants to review and deploy pending changes (the items field controls which changes are preselected). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.'
'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy review page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Use page "compare" when the user wants to review and deploy pending changes (the items field controls which changes are preselected). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.'
// Non-arg inputs the URL builder needs: the chat's operating workspace (the compare
// page cannot fall back to its own store default inside a session preview) and the
@@ -0,0 +1,142 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { enterpriseLicense } from '$lib/stores'
import { ResourceService } from '$lib/gen'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { TRIGGER_PAGES, type PageItemRef, type TriggerKind } from './previewPaths'
import type { SessionRuntime } from './sessionRuntime.svelte'
let {
runtime,
item,
workspaceId,
reloadNonce = 0
}: {
runtime: SessionRuntime
item: PageItemRef
workspaceId: string
/** Bumped to reload the item from the server, discarding the mounted editor. */
reloadNonce?: number
} = $props()
// Mounted in the preview panel, outside the chat's own subtree: chat-aware components
// below would otherwise resolve the app-wide manager rather than this session's.
// Captured at init, as SessionEditorTarget does: descendants rely on its presence.
setContext(
'aiChatManager',
untrack(() => runtime.manager)
)
// A session acts on its (possibly forked) workspace without switching the navigation
// store, and the trigger editors read theirs from this seam.
setTriggerWorkspace(() => workspaceId)
type TriggerEditorHandle = { openEdit: (path: string, isFlow: boolean) => Promise<void> }
type EditorModule = { default: any }
const TRIGGER_EDITORS: Record<TriggerKind | 'schedule', () => Promise<EditorModule>> = {
schedule: () => import('$lib/components/triggers/schedules/ScheduleEditorInner.svelte'),
http: () => import('$lib/components/triggers/http/RouteEditorInner.svelte'),
websocket: () =>
import('$lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte'),
postgres: () => import('$lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte'),
kafka: () => import('$lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte'),
nats: () => import('$lib/components/triggers/nats/NatsTriggerEditorInner.svelte'),
mqtt: () => import('$lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte'),
amqp: () => import('$lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte'),
sqs: () => import('$lib/components/triggers/sqs/SqsTriggerEditorInner.svelte'),
gcp: () => import('$lib/components/triggers/gcp/GcpTriggerEditorInner.svelte'),
azure: () => import('$lib/components/triggers/azure/AzureTriggerEditorInner.svelte'),
email: () => import('$lib/components/triggers/email/EmailTriggerEditorInner.svelte')
}
const triggerKey = $derived(
item.kind === 'schedule' ? 'schedule' : item.kind === 'trigger' ? item.triggerKind : undefined
)
const eeLocked = $derived(
item.kind === 'trigger' && !!TRIGGER_PAGES[item.triggerKind].ee && !$enterpriseLicense
)
let variableEditor: VariableEditor | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let triggerEditor: TriggerEditorHandle | undefined = $state()
// Each editor loads through the same call its list page makes, once its instance is bound.
// Keyed below on everything that names what it shows, so each binding is a fresh instance.
$effect(() => {
const path = item.path
const v = variableEditor
const r = resourceEditor
const t = triggerEditor
untrack(() => {
v?.editVariable(path)
if (r) void openResource(r, path)
void t?.openEdit(path, false)
})
})
// An agent is edited as JSON here: the generic form would render its configuration field by
// field and write a default into each one the value leaves out, drafting just by opening.
async function openResource(editor: ResourceEditorDrawer, path: string) {
let resourceType: string | undefined
try {
resourceType = (await ResourceService.getResource({ workspace: workspaceId, path }))
.resource_type
} catch {
// A draft-only resource has no row yet; the editor reads the draft itself.
}
if (editor !== resourceEditor) return
await editor.initEdit(path, { json: resourceType === 'ai_agent' })
}
// A save can move the item; the tab follows it, which remounts the editor on what was
// written. Saving in place remounts it too: the editors keep the pre-save baseline, and
// their drawers only ever relied on being closed after a save.
let savedNonce = $state(0)
function onSaved(path: string | undefined) {
if (path && path !== item.path) {
runtime.previewTabs.retargetPageItem(item, { ...item, path })
} else {
savedNonce++
}
}
</script>
{#snippet loading()}
<div class="flex-1 flex items-center justify-center text-tertiary">
<Loader2 class="animate-spin" />
</div>
{/snippet}
<div class="flex h-full min-h-0 flex-col">
{#if eeLocked}
<div class="p-4 text-sm text-secondary">This trigger requires an enterprise license.</div>
{:else}
{#key `${item.kind}:${triggerKey}:${item.path}:${workspaceId}:${reloadNonce}:${savedNonce}`}
{#if item.kind === 'variable'}
<VariableEditor bind:this={variableEditor} inline workspace={workspaceId} {onSaved} />
{:else if item.kind === 'resource'}
<ResourceEditorDrawer
bind:this={resourceEditor}
inline
workspace={workspaceId}
on:refresh={(e) => onSaved(typeof e.detail === 'string' ? e.detail : undefined)}
onRestored={() => savedNonce++}
/>
{:else if triggerKey}
{#await TRIGGER_EDITORS[triggerKey]()}
{@render loading()}
{:then Module}
<Module.default
bind:this={triggerEditor}
useDrawer
inline
onUpdate={(path?: string) => onSaved(path)}
/>
{/await}
{/if}
{/key}
{/if}
</div>
@@ -103,12 +103,19 @@
applyPageIframeTheme(darkMode)
})
// A page item's editor reads its draft only when it loads, so a reload remounts it.
let pageItemReloadNonce = $state(0)
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
// iframe fallback (a separate page) has to be told to refresh.
if (slot.kind === 'editor') return
if (slot.kind === 'pageitem') {
pageItemReloadNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -311,6 +318,22 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'pageitem' && mounted && runtime}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
<!-- Waits for the host element, as the run form does: the editors' own drawers and
modals portal when they mount, and resolve their host only then. -->
{#if overlayHostEl}
{#await import('./PageItemEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default {runtime} item={slot.ref} {workspaceId} reloadNonce={pageItemReloadNonce} />
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -197,8 +197,8 @@
return previewTargetForDeployKind(item.deployKind, item.path)
}
// The row's primary action is the preview; kinds the panel can't host
// (triggers, schedules, resources, variables) fall back to their diff.
// The row's primary action is the preview; kinds the panel can't host fall back
// to their diff.
function openRow(item: DeployItem) {
if (previewTargetFor(item)) openInPreview(item)
else openDrawer(item.key)
@@ -20,6 +20,7 @@ import {
type TriggerKind
} from './previewPaths'
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
import { isSessionPreviewFrame } from './sessionMode.svelte'
// The draft each page's drawer edits. The preview loads the page in its own
// document and reads the draft back from the server, so opening a session has to
@@ -87,6 +88,34 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin
history.replaceState(history.state, '', `${pathname}${search}${anchor}`)
}
/**
* Inside a session preview frame, hand a list page row up to the session, which edits it in
* a tab of its own. True when handed off: the caller must then not open its drawer. False
* off that page, and outside a preview frame, where the drawer is how the row is edited.
*/
export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean {
if (!itemPath || !isSessionPreviewFrame()) return false
if (stripBase(window.location.pathname) !== pagePath) return false
try {
window.parent.postMessage(
{ type: 'wm.session.openPageItem', pagePath, path: itemPath },
window.location.origin
)
} catch {
return false
}
// A frame left on a row's hash claims a row nobody has open here, and reopens its tab on
// every reload. Not through the router: these pages open their drawer from the hash. Once
// more after the event: a row link's `href="#<path>"` lands after its click handler.
const dropAnchor = () => {
const { pathname, search, hash } = window.location
if (hash) history.replaceState(history.state, '', `${pathname}${search}`)
}
dropAnchor()
setTimeout(dropAnchor, 0)
return true
}
/**
* Drop the row a list page deep-links, once its drawer closes. The hash is how the row was
* requested; leaving it behind makes the location claim a drawer that is no longer open —
@@ -48,6 +48,76 @@ export const TRIGGER_PAGES: Record<TriggerKind, { path: string; label: string; e
email: { path: '/email_triggers', label: 'Email triggers' }
}
/** A workspace item edited from its list page rather than at an editor route: a variable,
* resource, schedule or trigger. A session hosts each in a tab of its own. */
export type PageItemRef =
| { kind: 'variable' | 'resource' | 'schedule'; path: string }
| { kind: 'trigger'; triggerKind: TriggerKind; path: string }
/** The list page a page item is edited from. */
export function pageItemListPath(ref: PageItemRef): string {
switch (ref.kind) {
case 'variable':
return VARIABLES_PATH
case 'resource':
return RESOURCES_PATH
case 'schedule':
return SCHEDULES_PATH
case 'trigger':
return TRIGGER_PAGES[ref.triggerKind].path
}
}
/** The page item a list page's row names, or undefined for a page that lists none. */
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
const clean = stripBase(pagePath)
if (clean === VARIABLES_PATH) return { kind: 'variable', path }
if (clean === RESOURCES_PATH) return { kind: 'resource', path }
if (clean === SCHEDULES_PATH) return { kind: 'schedule', path }
const trigger = Object.entries(TRIGGER_PAGES).find(([, p]) => p.path === clean)
return trigger ? { kind: 'trigger', triggerKind: trigger[0] as TriggerKind, path } : undefined
}
const PAGE_ITEM_ROUTE = /^pageitem:(variable|resource|schedule|trigger\.([a-z]+))\/([^?#]+)$/
// A scheme rather than a path, like artifacts: the tab mounts the item's editor in process,
// so there is no page a frame could load. The path is encoded whole, so its slashes cannot
// be read as part of the scheme.
export function pageItemUrl(ref: PageItemRef): string {
const kind = ref.kind === 'trigger' ? `trigger.${ref.triggerKind}` : ref.kind
return `pageitem:${kind}/${encodeURIComponent(ref.path)}`
}
export function parsePageItemRoute(url: string): PageItemRef | null {
const m = url.match(PAGE_ITEM_ROUTE)
if (!m) return null
let path: string
try {
path = decodeURIComponent(m[3])
} catch {
return null
}
if (m[2] !== undefined) {
if (!(m[2] in TRIGGER_PAGES)) return null
return { kind: 'trigger', triggerKind: m[2] as TriggerKind, path }
}
return { kind: m[1] as 'variable' | 'resource' | 'schedule', path }
}
/** Singular human name of a page item's kind, e.g. "Kafka trigger". */
export function pageItemKindLabel(ref: PageItemRef): string {
switch (ref.kind) {
case 'variable':
return 'Variable'
case 'resource':
return 'Resource'
case 'schedule':
return 'Schedule'
case 'trigger':
return TRIGGER_PAGES[ref.triggerKind].label.replace(/s$/, '')
}
}
/** Label a trigger list page from its (base-stripped) pathname, or undefined. */
export function triggerLabelForPath(path: string): string | undefined {
const clean = stripBase(path)
@@ -1,6 +1,14 @@
import type { SessionPreviewTab } from './sessionState.svelte'
import { whereIs } from './sessionPreviewTabs.svelte'
import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths'
import {
pageItemListPath,
pageItemUrl,
parsePageItemRoute,
stripBase,
TRIGGER_PAGES,
type PageItemRef,
type TriggerKind
} from './previewPaths'
// Which list pages a completed chat tool can change, as base-stripped paths
// (e.g. `/schedules`). This allowlist is the single source of truth for "does
@@ -13,23 +21,26 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths'
// deliberately absent: every editable item is a live in-process editor that
// self-syncs from the store the chat mutates, so its tab needs no reload — and
// no list page we preview lists open drafts. They fall through to NO_RELOAD.
// This "live editors self-sync, only list pages reload" invariant is the reason
// the callers below and in the sessions page reload nothing for item tabs.
export type ToolReloadEffect = { pages: string[] }
const NO_RELOAD: ToolReloadEffect = { pages: [] }
//
// Page items (variables, resources, schedules, triggers) are the exception among
// 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: [] }
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
switch (name) {
case 'write_schedule':
return { pages: ['/schedules'] }
return withItem(['/schedules'], itemRef('schedule', args))
case 'write_trigger':
return { pages: triggerPages(args?.kind) }
return withItem(triggerPages(args?.kind), itemRef('trigger', args, args?.kind))
case 'write_resource':
return { pages: ['/resources'] }
return withItem(['/resources'], itemRef('resource', args))
case 'write_variable':
return { pages: ['/variables'] }
return withItem(['/variables'], itemRef('variable', args))
case 'create_folder':
return { pages: ['/folders'] }
return { pages: ['/folders'], items: [] }
// 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.
@@ -37,12 +48,31 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
case 'discard_local_draft':
case 'deploy_workspace_item':
case 'rebase_draft':
return { pages: pagesForItemType(args?.type, args) }
return withItem(
pagesForItemType(args?.type, args),
itemRef(args?.type, args, args?.trigger_kind)
)
default:
return NO_RELOAD
}
}
function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect {
return { pages, items: item && pages.length ? [item] : [] }
}
function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined {
const path = args?.path
if (typeof path !== 'string' || !path) return undefined
if (type === 'variable' || type === 'resource' || type === 'schedule') {
return { kind: type, path }
}
if (type === 'trigger' && (triggerKind as string) in TRIGGER_PAGES) {
return { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path }
}
return undefined
}
function pagesForItemType(type: unknown, args: any): string[] {
switch (type) {
case 'schedule':
@@ -63,14 +93,23 @@ function triggerPages(kind: unknown): string[] {
return page ? [page.path] : []
}
// The open tabs a page-reload should refresh: those whose observed page path is
// in `pages`. Item-editor and pipeline tab routes are never list pages, so they
// never match (see the self-sync invariant above). Pure over a tab snapshot so
// the sessions page can reload by id and this stays unit-testable.
// The open tabs a reload should refresh: list-page tabs whose observed page path is
// in `pages`, and page item tabs on those pages — only the named ones when a tool
// named its item. Item-editor and pipeline tab routes are never list pages, so they
// never match (see the self-sync invariant above). Pure over a tab snapshot so the
// sessions page can reload by id and this stays unit-testable.
export function tabsToReload(
tabs: SessionPreviewTab[],
pages: ReadonlySet<string>
pages: ReadonlySet<string>,
items: ReadonlySet<string> = new Set()
): SessionPreviewTab[] {
if (pages.size === 0) return []
return tabs.filter((t) => pages.has(stripBase(whereIs(t))))
return tabs.filter((t) => {
const pageItem = parsePageItemRoute(t.url)
if (!pageItem) return pages.has(stripBase(whereIs(t)))
const listPath = pageItemListPath(pageItem)
if (!pages.has(listPath)) return false
const named = [...items].some((u) => pageItemListPath(parsePageItemRoute(u)!) === listPath)
return !named || items.has(pageItemUrl(pageItem))
})
}
@@ -3,8 +3,12 @@ import {
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
pageItemForListPath,
pageItemListPath,
pageItemUrl,
pageKey,
pageHref,
parsePageItemRoute,
parsePreviewItemRoute,
RESOURCES_PATH,
RUNS_PATH,
@@ -14,17 +18,22 @@ import {
WORKSPACE_SETTINGS_PATH,
triggerLabelForPath,
TRIGGER_PAGES,
type PageItemRef,
type PreviewItemRoute,
type TriggerKind
} from './previewPaths'
// Re-exported so the preview code that already reads locations through this module keeps
// one import, while a caller needing only a path can reach for the leaf instead.
export {
pageItemListPath,
pageItemUrl,
pageKey,
pageHref,
parsePageItemRoute,
parsePreviewItemRoute,
stripBase,
TRIGGER_PAGES,
type PageItemRef,
type PreviewItemRoute,
type TriggerKind
}
@@ -68,6 +77,7 @@ export type PreviewTarget =
| { type: 'item'; item: WorkspaceItem }
| { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget }
| { type: 'runform'; toolCallId: string; label: string }
| { type: 'pageitem'; ref: PageItemRef }
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
@@ -117,6 +127,26 @@ export function drawerAnchorFor(location: string): string | undefined {
return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined
}
/** The item a list-page location deep-links, as a tab of its own: a session edits these
* in process, so the list page's drawer is never where one belongs. */
export function pageItemForLocation(location: string): PageItemRef | undefined {
const anchor = drawerAnchorFor(location)
if (!anchor) return undefined
let path: string
try {
path = decodeURIComponent(anchor)
} catch {
return undefined
}
return pageItemForListPath(location, path)
}
/** A location with a deep-linked row replaced by that row's own tab; any other unchanged. */
export function pageItemLocation(location: string): string {
const ref = pageItemForLocation(location)
return ref ? pageItemUrl(ref) : location
}
// Query params the preview host injects into an iframe URL (`nomenubar` hides the nav,
// `workspace` scopes the page). Never part of what a location means.
const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const
@@ -126,7 +156,7 @@ const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const
export function canonicalizeObservedLoc(loc: string): string {
// An artifact or a run form is a scheme, not a path — `new URL` would happily parse it
// and hand back a pathname with the scheme gone.
if (parseArtifactRoute(loc) || parseRunFormRoute(loc)) return loc
if (parseArtifactRoute(loc) || parseRunFormRoute(loc) || parsePageItemRoute(loc)) return loc
try {
const u = new URL(loc, 'http://_')
for (const p of INJECTED_PARAMS) u.searchParams.delete(p)
@@ -201,6 +231,8 @@ export function describeLocation(loc: string): PreviewLocation {
// Identity is the call, never the label: that carries the script's summary, so folding it
// in would open a second tab for the same form whenever the summary differed.
if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' }
const pageItem = parsePageItemRoute(loc)
if (pageItem) return { identity: pageItemUrl(pageItem), view: '', anchor: '' }
const canonical = canonicalizeObservedLoc(loc)
const path = stripBase(canonical)
const bare = canonical.split('#')[0]
@@ -322,6 +354,15 @@ export function previewLocationContext(loc: string): {
location: string
open?: string
} {
// Told as its list page with the item open, the shape the model already reads for a row
// whose drawer is open — which is all a page item tab is to it.
const pageItem = parsePageItemRoute(loc)
if (pageItem) {
return {
...previewLocationContext(pageItemListPath(pageItem)),
open: promptSafe(pageItem.path)
}
}
const { identity, anchor } = describeLocation(loc)
const bare = canonicalizeObservedLoc(loc).split('#')[0]
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
@@ -370,6 +411,8 @@ export function previewLocationLabel(url: string): string {
if (artifact) return artifact.name || 'Artifact'
const runForm = parseRunFormRoute(url)
if (runForm) return runForm.label || 'Run form'
const pageItem = parsePageItemRoute(url)
if (pageItem) return pageItem.path.split('/').pop() || pageItem.path
const page = matchReusablePage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
@@ -492,6 +535,7 @@ export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'pageitem'; ref: PageItemRef }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
@@ -499,6 +543,8 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version }
const runForm = parseRunFormRoute(url)
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
const pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
@@ -47,6 +47,17 @@ export function withMenuHidden(url: string, workspaceId?: string): string {
}
}
// True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag
// the preview always sets and the logged layout stickies into sessionStorage.
export function isSessionPreviewFrame(): boolean {
if (typeof window === 'undefined' || window.self === window.top) return false
try {
return sessionStorage.getItem('nomenubar_embedded') === 'true'
} catch {
return false
}
}
// Append `?workspace=` to a canonical route so a full-page navigation (e.g.
// "Open in workspace") lands on the session's effective workspace instead of
// the navigation workspace. Unlike withMenuHidden, the menu is kept visible —
@@ -8,7 +8,10 @@ import {
describeLocation,
matchPreviewPage,
showsView,
pageItemLocation,
pageItemUrl,
parseArtifactRoute,
parsePageItemRoute,
parsePipelineRoute,
previewLocationContext,
promptSafe,
@@ -24,6 +27,12 @@ import {
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
import type { Kind } from '$lib/utils_deployable'
import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
import {
pageItemKindLabel,
TRIGGER_PAGES,
type PageItemRef,
type TriggerKind
} from './previewPaths'
// The single live owner of a session's preview tabs. Runs behind a small
// interface both the sessions page (renderer) and the `open_preview` tool cross,
@@ -78,7 +87,10 @@ function keptVersion(
// scheme. `onto` is the tab about to be written, passed wherever one is being re-pointed so
// that every such path keeps its pin.
function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string {
if (target.type === 'page') return target.href
// A list page asked for with a row anchored is that row's own tab: its drawer would only
// open the editor a page item tab already hosts, inside a frame of its own.
if (target.type === 'page') return pageItemLocation(target.href)
if (target.type === 'pageitem') return pageItemUrl(target.ref)
if (target.type === 'artifact') {
return artifactUrl(target.id, target.name, keptVersion(target, onto))
}
@@ -152,10 +164,9 @@ export function previewTargetForSessionTarget(
// Adapt a deployable item's layout kind (the session review dock speaks `Kind`,
// not SessionTarget) to a preview destination: the three live editors, data
// pipelines, plus legacy drag-and-drop apps, which the panel hosts as an iframe
// over their edit route. Every other kind maps to undefined — not for lack of any
// route (a variable or trigger has a list page the panel can host) but because
// there is no item editor to preview, so their row falls back to the diff. The
// pipelines, page items (variables, resources, schedules, triggers), plus legacy
// 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 {
if (kind === 'app') {
@@ -164,6 +175,16 @@ export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTar
if (kind === 'script' || kind === 'flow' || kind === 'raw_app') {
return previewTargetForSessionTarget(kind, path)
}
if (kind === 'variable' || kind === 'resource' || kind === 'schedule') {
return { type: 'pageitem', ref: { kind, path } }
}
const triggerKind = kind.endsWith('_trigger') ? kind.slice(0, -'_trigger'.length) : undefined
if (triggerKind && triggerKind in TRIGGER_PAGES) {
return {
type: 'pageitem',
ref: { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path }
}
}
// A pipeline's editor is its folder's graph view, not its bundle path.
if (kind === 'data_pipeline') {
const folder = pipelineFolderFromBundlePath(path)
@@ -189,7 +210,11 @@ export function hydratePreviewTabs(session: {
seen.add(t.id)
// Rebuilt field-by-field so stray properties on old saved records (e.g. the
// retired `pinned` flag) don't survive hydration and get persisted back.
tabs.push({ id: t.id, url: t.url, loc: t.loc || t.url })
// A list page saved with a row's drawer open comes back as that row's own tab.
const url = pageItemLocation(t.url)
const loc = t.loc || t.url
const stale = parsePageItemRoute(url) || pageItemLocation(loc) !== loc
tabs.push({ id: t.id, url, loc: stale ? url : loc })
}
if (tabs.length > 0) {
const wantActive = session.activePreviewTabId
@@ -290,22 +315,14 @@ export class SessionPreviewTabs {
// Drift is a change of what the frame *shows*, not of its URL string: a page
// writing its own filter defaults back is not the user navigating away.
const drifted = !showsView(tab.loc, url)
// Both cases the browser will not act on, decided here because this is where the
// old and new commands are both in hand: re-commanding the URL a drifted frame
// already carries moves nothing, and moving to another fragment resolves within the
// same document — so a list page never re-runs the `#<path>` read that opens a row.
// Dropping the fragment is not one of them: the same-document path applies only to a
// target that has one, so the browser loads the page — closing the drawer by itself —
// and forcing a second load races that one back onto the row.
const fragmentOnly =
!commandUnchanged && url.includes('#') && tab.url.split('#')[0] === url.split('#')[0]
// Decided here because this is where the old and new commands are both in hand:
// re-commanding the URL a drifted frame already carries moves nothing.
retargetTab(tab, url)
if ((commandUnchanged && drifted) || fragmentOnly) this.pulseReload(tab.id)
if (commandUnchanged && drifted) this.pulseReload(tab.id)
}
// Force the host to reload the iframe. A navigation onto the tab's exact current URL
// changes nothing, so URL-driven behavior — a `#<path>` opening a drawer the user has
// since closed — would never re-fire.
// Force the host to reload the tab. A navigation onto the tab's exact current URL
// changes nothing, so URL-driven behavior would never re-fire.
pulseReload(id: string): void {
this.#reloadPulse = { id, nonce: this.#reloadPulse.nonce + 1 }
}
@@ -435,21 +452,21 @@ export class SessionPreviewTabs {
// shows this. The tab on this exact view wins over any other on the page —
// `new_tab` puts two views side by side, and retargeting whichever sits first
// would overwrite the other and leave both on the same row.
const shown = opts?.forceNewTab
? undefined
: (this.#tabs.find((t) => showsView(t.loc, url)) ??
this.#tabs.find((t) => describeLocation(t.loc).identity === describeLocation(url).identity))
// A page item stays one tab whatever the opener asks: two would hold two drafts of it.
const shown =
opts?.forceNewTab && !parsePageItemRoute(url)
? undefined
: (this.#tabs.find((t) => showsView(t.loc, url)) ??
this.#tabs.find(
(t) => describeLocation(t.loc).identity === describeLocation(url).identity
))
if (shown) {
const same = showsView(shown.loc, url)
if (same) {
// The frame is already here, but record what was asked for: `url` is what the
// tab persists and remounts from, so leaving it on where the frame started
// sends a refresh back to the row the user has since moved off.
// sends a refresh back to the view the user has since moved off.
recordCommand(shown, url)
// Nothing to navigate to, so nothing would re-run: the list pages read their
// `#<path>` once per document, and the drawer it opens may since have been
// closed. Only a forced load can bring it back.
if (describeLocation(url).anchor) this.pulseReload(shown.id)
} else {
this.#retarget(shown, url)
}
@@ -512,7 +529,17 @@ export class SessionPreviewTabs {
return
}
}
this.#retarget(t, targetUrl(target, t))
// One tab per page item, as for editors: two would hold two drafts of one item.
const url = targetUrl(target, t)
if (parsePageItemRoute(url)) {
const existing = this.#tabs.find((x) => x.url === url)
if (existing && existing.id !== t.id) {
this.#activeId = existing.id
this.#flush()
return
}
}
this.#retarget(t, url)
this.#flush()
}
@@ -574,6 +601,17 @@ export class SessionPreviewTabs {
this.#flush()
}
/** Follow a page item its editor saved under a new path, in place. */
retargetPageItem(from: PageItemRef, to: PageItemRef): void {
const fromUrl = pageItemUrl(from)
const toUrl = pageItemUrl(to)
if (fromUrl === toUrl) return
const tab = this.#tabs.find((t) => t.url === fromUrl)
if (!tab) return
retargetTab(tab, toUrl)
this.#flush()
}
closeArtifact(artifactId: string): void {
const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId)
if (tab) this.close(tab.id)
@@ -606,23 +644,14 @@ export class SessionPreviewTabs {
}
// Feed back the location an iframe reported on load (only the page can read
// contentWindow.location). Updates the observed `loc`; `url` follows only when a
// drawer closed (below), and the host navigates on a command it isn't already at,
// so that write does not move the frame.
// contentWindow.location). Updates the observed `loc` only: the host navigates on a
// command it isn't already at, and an in-frame move is the user browsing.
observeLocation(id: string, loc: string): void {
const t = this.#tabs.find((x) => x.id === id)
if (!t) return
const canonical = canonicalizeObservedLoc(loc)
if (t.loc === canonical) return
t.loc = canonical
// Closing a drawer drops the row from the frame's URL. The command has to follow, or
// the tab reopens it on the next mount — the iframe loads `url`, not `loc`. Only the
// anchor: any other in-frame move is the user browsing, which must not re-command.
const commanded = describeLocation(t.url)
const observed = describeLocation(canonical)
if (commanded.anchor && !observed.anchor && commanded.identity === observed.identity) {
t.url = t.url.split('#')[0]
}
this.#flush()
}
@@ -730,6 +759,7 @@ export function describePreview(
const lines = tabs.map((t) => {
const where = whereIs(t)
const artifact = parseArtifactRoute(where)
const pageItem = parsePageItemRoute(where)
const page = matchPreviewPage(where)
const pipelineFolder = parsePipelineRoute(where)
const route = parsePreviewItemRoute(where)
@@ -737,16 +767,19 @@ export function describePreview(
? // A pinned tab is not showing what the assistant last wrote, and nothing else in this
// summary would tell it so.
`artifact "${artifact.name || 'Artifact'}"${artifact.version ? ` (pinned to v${artifact.version})` : ''}`
: page
? `page "${page.label}"${previewLocationDetail(where)}`
: pipelineFolder
? `pipeline "${pipelineFolder}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: // Trigger list pages land here (they're outside PREVIEW_PAGES), and
// their `#<path>` is the trigger the drawer has open.
`${stripBase(where)}${previewLocationDetail(where)}`
const live = resolvePreviewTab(t.url).kind === 'editor' ? ', live editor' : ''
: pageItem
? `${pageItemKindLabel(pageItem).toLowerCase()} "${pageItem.path}"`
: page
? `page "${page.label}"${previewLocationDetail(where)}`
: pipelineFolder
? `pipeline "${pipelineFolder}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: // Trigger list pages land here (they're outside PREVIEW_PAGES), and
// 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 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.
@@ -177,10 +177,82 @@ describe('previewTargetForDeployKind', () => {
pipelineTarget
)
})
it('routes variables, resources, schedules and triggers to their own tab', () => {
expect(previewTargetForDeployKind('schedule', 'u/me/s')).toEqual({
type: 'pageitem',
ref: { kind: 'schedule', path: 'u/me/s' }
})
expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toEqual({
type: 'pageitem',
ref: { kind: 'trigger', triggerKind: 'http', path: 'u/me/t' }
})
expect(previewTargetForDeployKind('variable', 'u/me/v')).toEqual({
type: 'pageitem',
ref: { kind: 'variable', path: 'u/me/v' }
})
})
it('has no destination for kinds the preview panel cannot host', () => {
expect(previewTargetForDeployKind('schedule', 'u/me/s')).toBeUndefined()
expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toBeUndefined()
expect(previewTargetForDeployKind('variable', 'u/me/v')).toBeUndefined()
expect(previewTargetForDeployKind('folder', 'f/x')).toBeUndefined()
expect(previewTargetForDeployKind('resource_type', 'x')).toBeUndefined()
})
})
describe('page item tabs', () => {
const variable: PreviewTarget = {
type: 'pageitem',
ref: { kind: 'variable', path: 'u/me/token' }
}
it('opens a list page anchored at a row as that rows own tab, beside the list', () => {
const o = owner()
o.open({ type: 'page', href: '/routes', label: 'HTTP routes' })
o.open({ type: 'page', href: '/routes#u/me/a', label: 'HTTP routes' })
expect(o.tabs.map((t) => t.url)).toEqual(['/routes', 'pageitem:trigger.http/u%2Fme%2Fa'])
// Resources address their row through an extra segment.
o.open({ type: 'page', href: '/resources?owner=u#/resource/u/me/db', label: 'Resources' })
expect(o.tabs.at(-1)!.url).toBe('pageitem:resource/u%2Fme%2Fdb')
})
it('keeps one tab per item, whatever the opener asks', () => {
const o = owner()
o.open(variable)
o.open({ type: 'page', href: '/runs', label: 'Runs' })
expect(o.open({ type: 'page', href: '/variables#u/me/token', label: 'V' }).status).toBe(
'focused'
)
expect(o.open(variable, { forceNewTab: true }).status).toBe('focused')
o.navigate(variable)
expect(o.tabs).toHaveLength(2)
expect(o.activeId).toBe(o.tabs[0].id)
})
it('follows an item saved under a new path in place', () => {
const o = owner()
o.open(variable)
const id = o.tabs[0].id
o.retargetPageItem(
{ kind: 'variable', path: 'u/me/token' },
{ kind: 'variable', path: 'f/x/token' }
)
expect(o.tabs).toEqual([
{ id, url: 'pageitem:variable/f%2Fx%2Ftoken', loc: 'pageitem:variable/f%2Fx%2Ftoken' }
])
})
it('restores a tab saved on a rows drawer as that rows own tab', () => {
const snap = hydratePreviewTabs({
previewTabs: [
{ id: 'a', url: '/schedules#u/me/daily', loc: '/schedules?path=u#u/me/daily' },
// A drawer opened inside the frame, with the command still on the list.
{ id: 'b', url: '/variables', loc: '/variables#u/me/token' }
]
})
expect(snap.tabs).toEqual([
{ id: 'a', url: 'pageitem:schedule/u%2Fme%2Fdaily', loc: 'pageitem:schedule/u%2Fme%2Fdaily' },
{ id: 'b', url: '/variables', loc: '/variables' }
])
})
})
@@ -232,93 +304,34 @@ describe('SessionPreviewTabs.open', () => {
expect(o.activeId).toBe(firstId)
})
// A trigger list page is not a `matchReusablePage`, so the runtime's
// navigate-in-place path doesn't cover it: re-pointing the tab has to happen
// here or the panel keeps showing the previously opened row.
it('re-points a page tab whose hash target changed instead of only focusing it', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const firstId = o.activeId
// 'retargeted', not 'opened': the tab count is unchanged, and the caller
// reports that to the model.
const res = o.open(routes('/routes#u/me/b'))
expect(res.status).toBe('retargeted')
expect(o.tabs).toHaveLength(1)
expect(o.activeId).toBe(firstId)
expect(o.tabs[0].url).toBe('/routes#u/me/b')
// Back to the bare list: still the same tab, no longer anchored at a row.
expect(o.open(routes('/routes')).status).toBe('retargeted')
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].url).toBe('/routes')
// ...and asking for the view it already shows is a plain focus.
expect(o.open(routes('/routes')).status).toBe('focused')
})
// The list pages rewrite their own filter defaults into the URL after mount,
// and `loc` follows that rewrite. Matching on anything but the path made a tab
// stop recognizing itself, so every later open spawned a duplicate.
it('still recognizes a tab after the page rewrote its own filter params', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const id = o.tabs[0].id
o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a')
o.open(routes('/routes'))
o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger')
const res = o.open(routes('/routes#u/me/b'))
expect(res.status).toBe('retargeted')
expect(o.open(routes('/routes')).status).toBe('focused')
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].url).toBe('/routes#u/me/b')
})
// `new_tab` deliberately keeps two views of one page side by side. Reopening one of
// them must focus the tab already showing it, not retarget whichever tab happens to
// sit first in the strip — that would overwrite the other view and leave two tabs
// on the same row.
// sit first in the strip — that would overwrite the other view.
it('focuses the tab already showing the exact location before retargeting by path', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const runs = (href: string) => ({ type: 'page' as const, href, label: 'Runs' })
o.open(runs('/runs?path=u/me/a'))
const first = o.tabs[0].id
o.open(routes('/routes#u/me/b'), { forceNewTab: true })
o.open(runs('/runs?path=u/me/b'), { forceNewTab: true })
const second = o.tabs[1].id
expect(o.open(routes('/routes#u/me/b')).status).toBe('focused')
expect(o.open(runs('/runs?path=u/me/b')).status).toBe('focused')
expect(o.activeId).toBe(second)
expect(o.tabs).toHaveLength(2)
expect(o.tabs.find((t) => t.id === first)?.url).toBe('/routes#u/me/a')
})
// The list pages read their `#<path>` once per document, so a drawer the user closed
// inside the frame only comes back on a forced load — and re-commanding the location
// the tab already shows produces no navigation the host could act on.
it('forces a load when the requested row is the one the tab already shows', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const id = o.tabs[0].id
o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a')
const before = o.reloadPulse.nonce
expect(o.open(routes('/routes#u/me/a')).status).toBe('focused')
expect(o.reloadPulse).toEqual({ id, nonce: before + 1 })
})
// Dropping the fragment is a load in itself, so the forced one lands on top of a
// navigation still in flight — and reloads the row the command asked to leave.
it('does not force a load when the requested location drops the row', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const id = o.tabs[0].id
const before = o.reloadPulse.nonce
o.navigate(routes('/routes'))
expect(o.tabs.find((t) => t.id === id)?.url).toBe('/routes')
expect(o.reloadPulse.nonce).toBe(before)
expect(o.tabs.find((t) => t.id === first)?.url).toBe('/runs?path=u/me/a')
})
// Runs restores the user's "hide schedules" preference into the URL whenever a load
@@ -365,29 +378,11 @@ describe('SessionPreviewTabs.open', () => {
expect(o.tabs[0].url).toBe('/apps/edit/u/me/dash')
})
// Re-commanding the URL a tab is already pointed at changes nothing the host can
// see, so the frame would stay wherever the user navigated it inside the page.
it('forces a reload when the request matches the command but the frame drifted', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const id = o.tabs[0].id
// The user clicked another trigger inside the iframe.
o.observeLocation(id, '/routes#u/me/b')
const before = o.reloadPulse.nonce
const res = o.open(routes('/routes#u/me/a'))
expect(res.status).toBe('retargeted')
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].loc).toBe('/routes#u/me/a')
expect(o.reloadPulse.nonce).toBe(before + 1)
})
it('forceNewTab opts a page out of the location dedupe', () => {
const o = owner()
const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' })
o.open(routes('/routes#u/me/a'))
const res = o.open(routes('/routes#u/me/b'), { forceNewTab: true })
o.open(routes('/routes'))
const res = o.open(routes('/routes'), { forceNewTab: true })
expect(res.status).toBe('opened')
expect(o.tabs).toHaveLength(2)
})
@@ -512,59 +507,6 @@ describe('SessionPreviewTabs.open', () => {
})
})
describe('SessionPreviewTabs.open — commanded url', () => {
it('records the requested row even when the frame is already showing it', () => {
const o = owner()
o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' })
// The user moves to another row inside the frame.
o.observeLocation(o.tabs[0].id, '/routes#u/me/b')
o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' })
// `url` is what a refresh and a remount reload from, so it has to follow.
expect(o.tabs[0].url).toBe('/routes#u/me/b')
expect(o.tabs).toHaveLength(1)
})
})
describe('SessionPreviewTabs.observeLocation', () => {
it('drops the row from the command when the frame closes its drawer', () => {
const o = owner()
o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' })
// The page clears its own hash when the drawer closes.
o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger')
// The iframe mounts from `url`, so a remount would otherwise reopen the drawer.
expect(o.tabs[0].url).toBe('/routes')
})
it('leaves the command alone when the user just browses inside the frame', () => {
const o = owner()
o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' })
o.observeLocation(o.tabs[0].id, '/routes#u/me/b')
expect(o.tabs[0].url).toBe('/routes#u/me/a')
})
})
describe('SessionPreviewTabs.open — forced loads', () => {
it('pulses when only the fragment changes, since the browser would not load', () => {
const o = owner()
o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' })
const before = o.reloadPulse.nonce
o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' })
// Same document: the browser resolves the new fragment without a load, so the
// list page never re-runs the `#<path>` read that opens the row.
expect(o.reloadPulse.nonce).toBeGreaterThan(before)
expect(o.tabs).toHaveLength(1)
})
it('does not pulse when the document itself changes', () => {
const o = owner()
o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' })
const before = o.reloadPulse.nonce
o.open({ type: 'page', href: '/schedules#u/me/a', label: 'S' })
// Different page: src changes, the browser loads it, nothing to force.
expect(o.reloadPulse.nonce).toBe(before)
})
})
describe('SessionPreviewTabs.navigate', () => {
it('retargets the active tab to an editor item', () => {
const o = owner()
@@ -56,7 +56,10 @@ import {
selectPreviewTabsToClose,
whereIs
} from './sessionPreviewTabs.svelte'
import { pageItemKindLabel } from './previewPaths'
import {
pageItemForLocation,
pageItemLocation,
parsePreviewItemRoute,
previewLocationContext,
previewLocationLabel,
@@ -381,7 +384,8 @@ function createRuntime(session: Session): SessionRuntime {
// What the side panel is showing, stamped on each user message so the chat
// 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.
// 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.
manager.activePreviewResolver = () => {
const owner = getRuntime(session.id)?.previewTabs
// What is on screen, not merely which tab is selected: the rule tells the model
@@ -389,7 +393,8 @@ function createRuntime(session: Session): SessionRuntime {
// point those at a page the user cannot see.
const tab = owner?.displayedTab
if (!tab) return undefined
if (resolvePreviewTab(tab.url).kind !== 'iframe') return undefined
const slotKind = resolvePreviewTab(tab.url).kind
if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined
return previewLocationContext(whereIs(tab))
}
// Pre-flight: materialise the (still-transient) session, then commit
@@ -1066,7 +1071,9 @@ async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise<vo
// and/or held by a live owner — would otherwise keep showing the old target.
// Must write through the live owner when one exists; a bare record write would
// be clobbered by the owner's next flush.
export function resetSessionPreviewTabs(sessionId: string, url: string): void {
export function resetSessionPreviewTabs(sessionId: string, seedUrl: string): void {
// A list page seeded on a row's drawer opens that row's own tab, as open() would.
const url = pageItemLocation(seedUrl)
const tabs = [{ id: 'session', url, loc: url }]
const rt = runtimes.get(sessionId)
if (rt) {
@@ -1144,12 +1151,17 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
// open_page dispatches here to show a workspace page (Runs/Schedules) as a page
// tab in the calling session's preview panel. Returns undefined when there is no
// session so open_page can fall back to browser navigation.
setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab }) => {
setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label: pageLabel, newTab }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
if (!sessionId) return undefined
const session = sessionState.sessions.find((s) => s.id === sessionId)
if (!session) return undefined
const owner = getOrCreateRuntime(session).previewTabs
// A page opened on one item is that item's tab, and the report has to name what opened.
const pageItem = pageItemForLocation(href)
const label = pageItem
? `the ${pageItemKindLabel(pageItem).toLowerCase()} ${promptSafe(pageItem.path)}`
: pageLabel
// open() owns the whole decision — which tab already shows this page, whether the
// requested view differs from what it shows, and whether a forced load is needed to
// re-fire a drawer. Deciding any of that again here means two predicates for one
@@ -14,6 +14,7 @@
import { pageDrawerSessionSource } from '../sessions/pageDrawerSession'
import { page } from '$app/state'
import { workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import TriggerHistoryButton from './TriggerHistoryButton.svelte'
interface Props {
@@ -62,6 +63,8 @@
triggerPath,
triggerKind
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
const canSave = $derived((permissions === 'write' && edit) || permissions === 'create')
@@ -79,7 +82,7 @@
? pageDrawerSessionSource(
triggerPagePath,
trigger?.isDraft ? undefined : triggerPath || trigger?.path,
$workspaceStore ?? undefined
wsId ?? undefined
)
: undefined
)
@@ -2,6 +2,7 @@
import { untrack } from 'svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -45,6 +46,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -63,6 +66,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -158,6 +162,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -397,36 +402,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit AMQP trigger ${initialPath}`
: `AMQP trigger ${initialPath}`
: 'New AMQP trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.amqp.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit AMQP trigger ${initialPath}`
: `AMQP trigger ${initialPath}`
: 'New AMQP trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'AMQP trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -11,6 +11,7 @@
import { AzureTriggerService } from '$lib/gen'
import { emptyStringTrimmed } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { RefreshCw } from 'lucide-svelte'
interface Props {
@@ -38,6 +39,8 @@
event_type_filters = $bindable(),
path = ''
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
type Edition = 'basic' | 'namespace'
type Delivery = 'push' | 'pull'
@@ -64,8 +67,8 @@
})
$effect(() => {
if (emptyStringTrimmed(subscription_name) && !emptyStringTrimmed(path) && $workspaceStore) {
const generated = `windmill-${$workspaceStore}-${path.replaceAll(/[^A-Za-z0-9-]/g, '-')}`
if (emptyStringTrimmed(subscription_name) && !emptyStringTrimmed(path) && wsId) {
const generated = `windmill-${wsId}-${path.replaceAll(/[^A-Za-z0-9-]/g, '-')}`
subscription_name = generated.slice(0, 50)
}
})
@@ -90,7 +93,7 @@
let scopeError = $state<string | undefined>(undefined)
async function loadScopeResources() {
if (!$workspaceStore || emptyStringTrimmed(azure_resource_path)) {
if (!wsId || emptyStringTrimmed(azure_resource_path)) {
scopeResources = []
return
}
@@ -99,11 +102,11 @@
try {
const result = is_namespace
? await AzureTriggerService.listAzureNamespaces({
workspace: $workspaceStore,
workspace: wsId,
path: azure_resource_path
})
: await AzureTriggerService.listAzureBasicTopics({
workspace: $workspaceStore,
workspace: wsId,
path: azure_resource_path
})
scopeResources = result
@@ -143,7 +146,7 @@
async function loadTopics() {
if (
!is_namespace ||
!$workspaceStore ||
!wsId ||
emptyStringTrimmed(azure_resource_path) ||
emptyStringTrimmed(scope_resource_id)
) {
@@ -154,7 +157,7 @@
topicsError = undefined
try {
const result = await AzureTriggerService.listAzureNamespaceTopics({
workspace: $workspaceStore,
workspace: wsId,
path: azure_resource_path,
requestBody: { scope_resource_id }
})
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -9,6 +10,7 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
@@ -79,6 +81,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -95,6 +98,8 @@
cloudDisabled = false
}: {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -110,6 +115,8 @@
onReset?: () => void
cloudDisabled?: boolean
} = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
let hasChanged = $derived(!deepEqual(getAzureConfig(), originalConfig ?? {}))
const azureConfig = $derived.by(getAzureConfig)
@@ -117,7 +124,7 @@
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_azure',
path: () => initialPath,
workspace: () => $workspaceStore,
workspace: () => wsId,
drawerLoading: () => drawerLoading,
getCfg: () => azureConfig,
applyCfg: loadTriggerConfig,
@@ -133,6 +140,7 @@
isFlow: boolean,
defaultValues?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return
drawerLoading = true
try {
drawer?.openDrawer()
@@ -205,7 +213,7 @@
}
try {
const s = await AzureTriggerService.getAzureTrigger({
workspace: $workspaceStore!,
workspace: wsId!,
path: initialPath,
getDraft: true
})
@@ -253,7 +261,7 @@
initialPath,
cfg,
edit,
$workspaceStore!,
wsId!,
usedTriggerKinds
)
if (isSaved) {
@@ -309,7 +317,7 @@
(force) =>
AzureTriggerService.setAzureTriggerMode({
path: initialPath,
workspace: $workspaceStore ?? '',
workspace: wsId ?? '',
requestBody: { mode: newMode, force }
}),
'Azure trigger'
@@ -353,36 +361,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Azure trigger ${initialPath}`
: `Azure trigger ${initialPath}`
: 'New Azure trigger'}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.azure.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Azure trigger ${initialPath}`
: `Azure trigger ${initialPath}`
: 'New Azure trigger'}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -41,6 +42,7 @@
let {
useDrawer = true,
inline = false,
hideTarget = false,
description = undefined,
isEditor = false,
@@ -127,6 +129,7 @@
defaultConfig?: Partial<NewEmailTrigger>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
@@ -486,36 +489,44 @@
{/if}
{/snippet}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit email trigger ${initialPath}`
: `Email trigger ${initialPath}`
: 'New email trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render saveButton()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="700px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.email.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit email trigger ${initialPath}`
: `Email trigger ${initialPath}`
: 'New email trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render saveButton()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'Email trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -90,6 +91,7 @@
let loadedUsesDefaultCredentials = $state(false)
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -106,6 +108,8 @@
cloudDisabled = false
}: {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -147,6 +151,7 @@
defaultValues?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return
drawerLoading = true
try {
drawer?.openDrawer()
@@ -405,36 +410,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
: `GCP Pub/Sub trigger ${initialPath}`
: 'New GCP Pub/Sub trigger'}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.gcp.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
: `GCP Pub/Sub trigger ${initialPath}`
: 'New GCP Pub/Sub trigger'}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'GCP Pub/Sub trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -1,5 +1,6 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import Label from '$lib/components/Label.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { bash } from 'svelte-highlight/languages'
@@ -32,9 +33,11 @@
isFlow = false,
captureLoading = false
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
let captureURL = $derived(
`${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
`${location.origin}${base}/api/w/${wsId}/capture_u/http/${
captureInfo?.isFlow ? 'flow' : 'script'
}/${captureInfo?.path.replaceAll('/', '.')}/${route_path ?? ''}`
)
@@ -5,6 +5,7 @@
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { HttpTriggerService, SettingService } from '$lib/gen'
// import { page } from '$app/state'
import { getHttpRoute } from './utils'
@@ -41,6 +42,8 @@
isDraftOnly = true,
showTestingBadge = false
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
let validateTimeout: number | undefined = undefined
@@ -74,7 +77,7 @@
workspaced_route: boolean
) {
return await HttpTriggerService.existsRoute({
workspace: $workspaceStore!,
workspace: wsId!,
requestBody: {
route_path,
http_method: method,
@@ -95,7 +98,7 @@
isValid = routeError === ''
})
let fullRoute = $derived(getHttpRoute('r', route_path, workspaced_route, $workspaceStore ?? ''))
let fullRoute = $derived(getHttpRoute('r', route_path, workspaced_route, wsId ?? ''))
$effect.pre(() => {
!http_method && (http_method = 'post')
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -22,6 +23,7 @@
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import {
canWrite,
capitalize,
@@ -79,6 +81,7 @@
let {
useDrawer = true,
inline = false,
hideTarget = false,
description = undefined,
isEditor = false,
@@ -93,6 +96,8 @@
trigger = undefined,
customSaveBehavior = undefined
} = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
// Form data state
let initialPath = $state('')
@@ -179,7 +184,7 @@
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_http',
path: () => initialPath,
workspace: () => $workspaceStore,
workspace: () => wsId,
drawerLoading: () => drawerLoading,
getCfg: () => routeConfig,
applyCfg: (c) => loadTriggerConfig(c as Partial<HttpTrigger>),
@@ -215,7 +220,7 @@
}
async function loadVariables() {
return await VariableService.listVariable({ workspace: $workspaceStore ?? '' })
return await VariableService.listVariable({ workspace: wsId ?? '' })
}
const authentication_options: AuthenticationOption[] = [
@@ -255,6 +260,7 @@
isFlow: boolean,
defaultConfig?: Partial<NewHttpTrigger>
) {
if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
@@ -393,7 +399,7 @@
return { overlay: undefined, noDeployed: false }
}
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
workspace: wsId!,
path: initialPath,
getDraft: true
})
@@ -419,7 +425,7 @@
initialPath,
saveCfg,
edit,
$workspaceStore!,
wsId!,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds
)
@@ -482,7 +488,7 @@
// and parent live at distinct URLs — no fork-conflict warning.
await HttpTriggerService.setHttpTriggerMode({
path: initialPath,
workspace: $workspaceStore ?? '',
workspace: wsId ?? '',
requestBody: { mode: newMode }
})
sendUserToast(`${capitalize(newMode)} HTTP trigger ${initialPath}`)
@@ -535,7 +541,7 @@
{#if authentication_method === 'windmill'}
<UserSettings
bind:this={userSettings}
newTokenWorkspace={$workspaceStore}
newTokenWorkspace={wsId}
newTokenLabel={`http-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
@@ -1057,36 +1063,40 @@
{/if}
{/snippet}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit ? (can_write ? `Edit route ${initialPath}` : `Route ${initialPath}`) : 'New route'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render saveButton()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="700px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.http.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit route ${initialPath}`
: `Route ${initialPath}`
: 'New route'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render saveButton()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'HTTP Route' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -40,6 +41,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -58,6 +61,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -158,6 +162,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -417,36 +422,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Kafka trigger ${initialPath}`
: `Kafka trigger ${initialPath}`
: 'New Kafka trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsButtons('sm')}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.kafka.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Kafka trigger ${initialPath}`
: `Kafka trigger ${initialPath}`
: 'New Kafka trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons('sm')}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'Kafka trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { untrack } from 'svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -49,6 +50,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -67,6 +70,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -153,6 +157,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -391,36 +396,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit MQTT trigger ${initialPath}`
: `MQTT trigger ${initialPath}`
: 'New MQTT trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.mqtt.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit MQTT trigger ${initialPath}`
: `MQTT trigger ${initialPath}`
: 'New MQTT trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'MQTT trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -35,6 +36,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -54,6 +57,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -141,6 +145,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -388,36 +393,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit NATS trigger ${initialPath}`
: `NATS trigger ${initialPath}`
: 'New NATS trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.nats.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit NATS trigger ${initialPath}`
: `NATS trigger ${initialPath}`
: 'New NATS trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'NATS trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Button, TabContent } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -57,6 +58,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
isEditor?: boolean
@@ -75,6 +78,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
isEditor = false,
@@ -242,6 +246,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -572,34 +577,42 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Postgres trigger ${initialPath}`
: `Postgres trigger ${initialPath}`
: 'New Postgres trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}{@render actionsSnippet()}{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render content()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.postgres.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Postgres trigger ${initialPath}`
: `Postgres trigger ${initialPath}`
: 'New Postgres trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}{@render actionsSnippet()}{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render content()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'Postgres trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
@@ -54,6 +55,7 @@
let {
useDrawer = true,
inline = false,
hideTarget = false,
docDescription = undefined,
allowDraft = false,
@@ -178,6 +180,7 @@
defaultCfg?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(SCHEDULES_PATH, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -1420,34 +1423,42 @@
</div>
{/snippet}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit schedule ${initialPath}`
: `View schedule ${initialPath}`
: 'New schedule'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
<div class="flex flex-row gap-4 items-center">
{@render saveButton()}
</div>
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render content()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer size="900px" bind:this={drawer} on:close={() => clearPageDrawerAnchor(SCHEDULES_PATH)}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit schedule ${initialPath}`
: `View schedule ${initialPath}`
: 'New schedule'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
<div class="flex flex-row gap-4 items-center">
{@render saveButton()}
</div>
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render content()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'Schedule' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -41,6 +42,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -59,6 +62,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -136,6 +140,7 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.sqs.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -370,36 +375,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit SQS trigger ${initialPath}`
: `SQS trigger ${initialPath}`
: 'New SQS trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.sqs.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit SQS trigger ${initialPath}`
: `SQS trigger ${initialPath}`
: 'New SQS trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsSnippet()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'SQS trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -9,6 +9,7 @@
import type { Schema } from '$lib/common'
import { FlowService, ScriptService, type Flow, type Script } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
import TestingBadge from '$lib/components/triggers/testingBadge.svelte'
@@ -31,6 +32,8 @@
isValid = $bindable(false),
showTestingBadge = false
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
let areRunnableArgsValid: boolean = $state(true)
@@ -42,11 +45,11 @@
try {
let scriptOrFlow: Script | Flow = url.startsWith('$flow:')
? await FlowService.getFlowByPath({
workspace: $workspaceStore!,
workspace: wsId!,
path: url.split(':')[1]
})
: await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
workspace: wsId!,
path: url.split(':')[1]
})
urlRunnableSchema = scriptOrFlow.schema as Schema
@@ -2,6 +2,7 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -25,6 +26,7 @@
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -56,6 +58,8 @@
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
@@ -75,6 +79,7 @@
let {
useDrawer = true,
inline = false,
description = undefined,
hideTarget = false,
hideTooltips = false,
@@ -90,6 +95,8 @@
onReset = undefined,
cloudDisabled = false
}: Props = $props()
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
let drawer: Drawer | undefined = $state()
let is_flow: boolean = $state(false)
@@ -139,7 +146,7 @@
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_websocket',
path: () => initialPath,
workspace: () => $workspaceStore,
workspace: () => wsId,
drawerLoading: () => drawerLoading,
getCfg: () => websocketCfg,
applyCfg: loadTriggerConfig,
@@ -179,6 +186,7 @@
isFlow: boolean,
defaultConfig?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.websocket.path, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -320,7 +328,7 @@
return { overlay: undefined, noDeployed: false }
}
const s = await WebsocketTriggerService.getWebsocketTrigger({
workspace: $workspaceStore!,
workspace: wsId!,
path: initialPath,
getDraft: true
})
@@ -348,8 +356,8 @@
try {
let schema: Schema | undefined = emptySchema()
let scriptOrFlow: Script | Flow = is_flow
? await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
: await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path })
? await FlowService.getFlowByPath({ workspace: wsId!, path })
: await ScriptService.getScriptByPath({ workspace: wsId!, path })
schema = scriptOrFlow.schema as Schema
if (schema && schema.properties) {
initialMessageRunnableSchemas[(is_flow ? 'flow/' : '') + path] = schema
@@ -380,7 +388,7 @@
initialPath,
saveCfg,
edit,
$workspaceStore!,
wsId!,
usedTriggerKinds
)
if (isSaved) {
@@ -412,7 +420,7 @@
(force) =>
WebsocketTriggerService.setWebsocketTriggerMode({
path: initialPath,
workspace: $workspaceStore ?? '',
workspace: wsId ?? '',
requestBody: { mode: newMode, force }
}),
'websocket trigger'
@@ -458,36 +466,44 @@
/>
{/if}
{#if useDrawer}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit WebSocket trigger ${initialPath}`
: `WebSocket trigger ${initialPath}`
: 'New WebSocket trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.websocket.path)}
>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit WebSocket trigger ${initialPath}`
: `WebSocket trigger ${initialPath}`
: 'New WebSocket trigger'}
on:close={drawer.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render config()}
</DrawerContent>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'WebSocket trigger' : ''} headerClass="grow min-w-0 h-[30px]">
@@ -13,6 +13,7 @@
} from '$lib/gen'
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { isSessionPreviewFrame } from '$lib/components/sessions/sessionMode.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
@@ -355,17 +356,6 @@
}
}
// True when this window is a sessions-preview iframe (embedded + nomenubar,
// which the preview always sets and stickies — see the menu-hide block above).
function isSessionPreviewEmbed(): boolean {
if (!embedded) return false
try {
return sessionStorage.getItem('nomenubar_embedded') === 'true'
} catch {
return false
}
}
// A job-detail navigation (/run/<id>) inside a preview tab should open the job in
// a NEW tab rather than navigate the current tab away from its page (e.g. clicking
// a job in the Runs tab keeps Runs put and opens the run beside it). Returns the
@@ -412,7 +402,7 @@
// instead of booting a second, disconnected editor in this frame. Cancel so
// the heavy editor never mounts here at all. Runs before the apps_raw reload
// below so a raw-app editor promotes rather than full-reloading the iframe.
if (isSessionPreviewEmbed()) {
if (isSessionPreviewFrame()) {
const target = previewEditorTarget(navigation.to?.url)
if (target) {
navigation.cancel()
@@ -84,7 +84,10 @@
openAgentEditor
} from '$lib/components/flows/agentEditorStore.svelte'
import { copilotInfo } from '$lib/aiStore'
import { setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
import {
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import ExploreAssetButton, {
@@ -141,6 +144,7 @@
* render its configuration as raw JSON. Both write the same resource draft, so the choice is
* presentational and either can open a path the other left a draft at. */
function openResourceEditor(path: string, resourceType: string | undefined) {
if (handOffPageDrawer(RESOURCES_PATH, path)) return
if (resourceType === 'ai_agent') {
// The generic editor anchors itself from `initEdit`; this one has to, or the URL, a
// refresh, and the AI session's idea of where you are all miss the open agent. Claim the
@@ -59,14 +59,18 @@
artifactKey,
itemDisplayName,
matchPreviewPage,
pageItemUrl,
pageKey,
parseArtifactRoute,
parsePageItemRoute,
type PageItemRef,
parseRunFormRoute,
parsePreviewItemRoute,
previewLocationLabel,
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
import { pageItemForListPath } from '$lib/components/sessions/previewPaths'
import {
leafKeyFor,
loadKind,
@@ -103,7 +107,8 @@
() => import('$lib/components/sessions/ScriptEditorView.svelte'),
() => import('$lib/components/sessions/FlowEditorView.svelte'),
() => import('$lib/components/sessions/RawAppEditorView.svelte'),
() => import('$lib/components/sessions/PipelineEditorView.svelte')
() => import('$lib/components/sessions/PipelineEditorView.svelte'),
() => import('$lib/components/sessions/PageItemEditorView.svelte')
]
for (const load of loaders) {
if (disposed) return
@@ -562,15 +567,17 @@
// Base-stripped list-page paths (e.g. `/schedules`) a chat round touched since
// the last flush — see toolReloadEffect for how tools map to pages.
let pendingPages = new Set<string>()
// The page items those tools named, as tab urls.
let pendingItems = new Set<string>()
// Reload the mounted list-page tabs a chat round changed, across all warm
// sessions (a hidden preview would otherwise show pre-mutation content on
// return). tabsToReload picks only the tabs whose page is in `pages`.
function reloadTabs(pages: Set<string>) {
function reloadTabs(pages: Set<string>, items: Set<string>) {
for (const s of warmSessions) {
const owner = getRuntime(s.id)?.previewTabs
if (!owner) continue
for (const tab of tabsToReload(owner.tabs, pages)) {
for (const tab of tabsToReload(owner.tabs, pages, items)) {
const key = tabKey(s.id, tab.id)
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
}
@@ -578,21 +585,25 @@
}
function flushReload() {
const pages = pendingPages
const items = pendingItems
pendingPages = new Set()
reloadTabs(pages)
pendingItems = new Set()
reloadTabs(pages, items)
}
$effect(() => {
// Debounced so a burst of writes (the AI editing several files) reloads once.
setToolCompletionListener((name, args) => {
const { pages } = toolReloadEffect(name, args)
const { pages, items } = toolReloadEffect(name, args)
if (pages.length === 0) return
for (const p of pages) pendingPages.add(p)
for (const item of items) pendingItems.add(pageItemUrl(item))
clearTimeout(reloadHandle)
reloadHandle = setTimeout(flushReload, 500)
})
return () => {
clearTimeout(reloadHandle)
pendingPages = new Set()
pendingItems = new Set()
setToolCompletionListener(undefined)
}
})
@@ -610,6 +621,22 @@
o.open(target)
})
})
// Variables, resources, schedules and triggers the chat links to open as tabs of their
// own here, rather than in the drawers the layout opens them in elsewhere.
$effect(() => {
return registerToolDisplayActionHandler('open_created_resource', (action) => {
if (action.type !== 'open_created_resource') return
const ref: PageItemRef | undefined =
action.resource === 'trigger'
? action.triggerKind && {
kind: 'trigger',
triggerKind: action.triggerKind,
path: action.path
}
: { kind: action.resource, path: action.path }
if (ref) owner?.open({ type: 'pageitem', ref })
})
})
// Editor-style breadcrumb over the previewed page. We only render clickable
// segments when the preview is sitting on a script/flow/app route — for any
@@ -745,7 +772,8 @@
const path =
tab.friendlyPath ??
listedItemFor(tab, workspace)?.draftPath ??
parsePreviewItemRoute(tab.loc)?.itemPath
parsePreviewItemRoute(tab.loc)?.itemPath ??
parsePageItemRoute(tab.loc)?.path
return path && path !== label ? `${label}\n${path}` : label
}
@@ -785,6 +813,14 @@
owner?.navigate({ type: 'item', item })
return
}
// A row opened on a list page inside a preview tab: its editor opens as a tab of its
// own, leaving the list where it is.
if (d.type === 'wm.session.openPageItem') {
if (typeof d.pagePath !== 'string' || typeof d.path !== 'string') return
const ref = pageItemForListPath(d.pagePath, d.path)
if (ref) owner?.open({ type: 'pageitem', ref })
return
}
// A job clicked inside a preview tab: open the run detail in a NEW tab so the
// originating page (e.g. Runs) stays put. open() focuses an existing tab for
// the same run rather than duplicating it.