feat(sessions): host schedule, resource and variable editors in process

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-05 14:10:40 +02:00
co-authored by Claude Opus 5
parent c37f59e22a
commit cc8d55eb57
11 changed files with 459 additions and 158 deletions
@@ -23,7 +23,8 @@
workspace = undefined,
disableChatOffset = false,
onRestored = undefined,
onSaved = undefined
onSaved = undefined,
useDrawer = true
}: {
workspace?: string
disableChatOffset?: boolean
@@ -31,6 +32,13 @@
/** Fires after Save has written, for a caller showing state derived from the
* resource — `onRestored` only covers restoring an old version. */
onSaved?: () => void
/**
* False renders the editor in place instead of in a drawer, for a host that
* gives it a pane of its own (a session's resource tab). Same convention as
* the trigger editors. `initEdit` still selects what is shown; there is no
* drawer to open, so it simply takes effect.
*/
useDrawer?: boolean
} = $props()
let drawer: Drawer | undefined = $state()
@@ -118,93 +126,126 @@
)
</script>
<Drawer
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => {
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
}
clearPageDrawerAnchor(RESOURCES_PATH)
}}
>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
bannerReserved={mode == 'edit'}
on:close={drawer?.closeDrawer}
{#snippet editorBody()}
{#await import('./ResourceEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
bind:selected
bind:viewJsonSchema
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{/snippet}
{#snippet draftBanner()}
<LocalDraftBanner
show={hasLocalDraft}
reserveSpace={mode == 'edit'}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
disabled={!canWriteSelected}
/>
{/snippet}
{#snippet editorActions()}
<!-- Only the drawer offers the hand-off: rendered inline the editor is
already inside the session it would open. -->
{#if useDrawer}
<OpenInSessionButton source={sessionSource} />
{/if}
{#if mode == 'edit' && path && effectiveWorkspace}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: History }}
on:click={() => historyDrawer?.openDrawer()}
>
History
</Button>
<WsSpecificVersions
kind="resource"
workspaceId={effectiveWorkspace}
initialPath={path}
bind:selected
/>
{/if}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
on:click={async () => {
// Closed before the write is awaited, the way it always was: `save()` toasts its
// own failures and never rejects, so waiting would only add visible lag to every
// caller of this drawer. `onSaved` still fires after the write lands.
const saved = resourceEditor?.save()
drawer?.closeDrawer()
await saved
onSaved?.()
}}
disabled={!canSave}
>
{#snippet titleExtra()}
{#if mode == 'new' && resource_type}
<IconedResourceType name={resource_type} silent width="20px" height="20px" />
{/if}
{/snippet}
{#await import('./ResourceEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
bind:selected
bind:viewJsonSchema
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
<LocalDraftBanner
show={hasLocalDraft}
reserveSpace={mode == 'edit'}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
disabled={!canWriteSelected}
/>
{/snippet}
{#snippet actions()}
<OpenInSessionButton source={sessionSource} />
{#if mode == 'edit' && path && effectiveWorkspace}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: History }}
on:click={() => historyDrawer?.openDrawer()}
>
History
</Button>
<WsSpecificVersions
kind="resource"
workspaceId={effectiveWorkspace}
initialPath={path}
bind:selected
/>
{/if}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
on:click={async () => {
// Closed before the write is awaited, the way it always was: `save()` toasts its
// own failures and never rejects, so waiting would only add visible lag to every
// caller of this drawer. `onSaved` still fires after the write lands.
const saved = resourceEditor?.save()
drawer?.closeDrawer()
await saved
onSaved?.()
}}
disabled={!canSave}
Save
</Button>
{/snippet}
{#if useDrawer}
<Drawer
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => {
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
}
clearPageDrawerAnchor(RESOURCES_PATH)
}}
>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
bannerReserved={mode == 'edit'}
on:close={drawer?.closeDrawer}
>
{#snippet titleExtra()}
{#if mode == 'new' && resource_type}
<IconedResourceType name={resource_type} silent width="20px" height="20px" />
{/if}
{/snippet}
{@render editorBody()}
{#snippet banner()}
{@render draftBanner()}
{/snippet}
{#snippet actions()}
{@render editorActions()}
{/snippet}
</DrawerContent>
</Drawer>
{:else}
<div class="flex flex-col h-full min-h-0">
<div class="flex flex-row items-center gap-2 justify-between px-4 py-2 border-b">
<span class="text-sm font-semibold truncate"
>{mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}</span
>
Save
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<div class="flex flex-row items-center gap-2 shrink-0">
{@render editorActions()}
</div>
</div>
{@render draftBanner()}
<div class="flex-1 min-h-0 overflow-auto p-4">
{@render editorBody()}
</div>
</div>
{/if}
<Drawer bind:this={historyDrawer} size="1200px">
<DrawerContent title="Versions History" on:close={historyDrawer?.closeDrawer} noPadding>
+112 -67
View File
@@ -39,7 +39,19 @@
// 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. Defaults to $workspaceStore.
let { workspace = undefined }: { workspace?: string } = $props()
let {
workspace = undefined,
useDrawer = true
}: {
workspace?: string
/**
* False renders the editor in place instead of in a drawer, for a host that
* gives it a pane of its own (a session's variable tab). Same convention as
* the trigger editors. `editVariable` still selects what is shown; there is
* no drawer to open, so it simply takes effect.
*/
useDrawer?: boolean
} = $props()
let curWs = $derived(workspace ?? $workspaceStore)
let editPath: string | undefined = $state(undefined)
@@ -307,73 +319,106 @@
}
</script>
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
on:close={drawer?.closeDrawer}
{#snippet draftBanner()}
<LocalDraftBanner
show={edit && selectedDirty}
reserveSpace={edit}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
if (!selected) return
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
})
}}
disabled={!can_write}
/>
{/snippet}
{#snippet editorActions()}
<!-- Only the drawer offers the hand-off: rendered inline the editor is
already inside the session it would open. -->
{#if useDrawer}
<OpenInSessionButton source={sessionSource} />
{/if}
{#if edit && curWs}
<WsSpecificVersions kind="variable" workspaceId={curWs} {initialPath} bind:selected />
{/if}
<Button
on:click={save}
disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
startIcon={{ icon: Save }}
variant="accent"
size="sm"
>
{#snippet banner()}
<LocalDraftBanner
show={edit && selectedDirty}
reserveSpace={edit}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
if (!selected) return
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
})
}}
disabled={!can_write}
/>
{/snippet}
<div class="flex flex-col gap-8 pb-2">
{#if !can_write}
<Alert type="warning" title="Only read access">
You only have read access to this resource and cannot edit it
</Alert>
{/if}
{edit ? 'Update' : 'Save'}
</Button>
{/snippet}
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
</Alert>
{/if}
{#snippet editorBody()}
<div class="flex flex-col gap-8 pb-2">
{#if !can_write}
<Alert type="warning" title="Only read access">
You only have read access to this resource and cannot edit it
</Alert>
{/if}
{#if current}
{#key current}
<VariableForm
bind:this={form}
bind:path={current.path}
bind:pathError
bind:variable={current.variable}
bind:labels={current.labels}
bind:wsSpecific={current.wsSpecific}
{initialPath}
deployTo={deployTo.current}
{can_write}
{edit}
onLoadSecret={loadSecret}
{workspace}
/>
{/key}
{/if}
</div>
{#snippet actions()}
<OpenInSessionButton source={sessionSource} />
{#if edit && curWs}
<WsSpecificVersions kind="variable" workspaceId={curWs} {initialPath} bind:selected />
{/if}
<Button
on:click={save}
disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
startIcon={{ icon: Save }}
variant="accent"
size="sm"
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
</Alert>
{/if}
{#if current}
{#key current}
<VariableForm
bind:this={form}
bind:path={current.path}
bind:pathError
bind:variable={current.variable}
bind:labels={current.labels}
bind:wsSpecific={current.wsSpecific}
{initialPath}
deployTo={deployTo.current}
{can_write}
{edit}
onLoadSecret={loadSecret}
{workspace}
/>
{/key}
{/if}
</div>
{/snippet}
{#if useDrawer}
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
on:close={drawer?.closeDrawer}
>
{#snippet banner()}
{@render draftBanner()}
{/snippet}
{@render editorBody()}
{#snippet actions()}
{@render editorActions()}
{/snippet}
</DrawerContent>
</Drawer>
{:else}
<div class="flex flex-col h-full min-h-0">
<div class="flex flex-row items-center gap-2 justify-between px-4 py-2 border-b">
<span class="text-sm font-semibold truncate"
>{edit ? `Update variable at ${initialPath}` : 'Add a variable'}</span
>
{edit ? 'Update' : 'Save'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<div class="flex flex-row items-center gap-2 shrink-0">
{@render editorActions()}
</div>
</div>
{@render draftBanner()}
<div class="flex-1 min-h-0 overflow-auto p-4">
{@render editorBody()}
</div>
</div>
{/if}
@@ -106,8 +106,10 @@
// 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
// iframe fallback (a separate page) has to be told to refresh. An entity
// editor is in-realm too: it holds a live UserDraft handle on the cell the
// chat's write seeds, and reloading would discard the user's edits with it.
if (slot.kind === 'editor' || slot.kind === 'entity') return
try {
const win = frame?.contentWindow
if (!win) return
@@ -310,6 +312,34 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'entity' && mounted}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
<!-- Dynamic import for the same reason as the editors above: these pull in
the runnable pickers and the resource-type schema forms. -->
{#if slot.entityKind === 'trigger_schedule'}
{#await import('./ScheduleEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default path={slot.path} {workspaceId} />
{/await}
{:else if slot.entityKind === 'resource'}
{#await import('./ResourceEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default path={slot.path} {workspaceId} />
{/await}
{:else}
{#await import('./VariableEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default path={slot.path} {workspaceId} />
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -0,0 +1,28 @@
<script lang="ts">
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import { untrack } from 'svelte'
let {
path,
workspaceId
}: {
/** The resource this tab edits (the row its location deep-links). */
path: string
/** The session's acting workspace, which the editor operates on instead of
* `$workspaceStore` (the nav workspace, which a session leaves put). */
workspaceId: string
} = $props()
let editor = $state<ResourceEditorDrawer | undefined>()
// Re-selects when the tab is pointed at another resource; the component keeps
// its identity across that, as it does for the drawer's row-to-row switch.
$effect(() => {
const p = path
const e = editor
if (!p || !e) return
untrack(() => void e.initEdit(p))
})
</script>
<ResourceEditorDrawer bind:this={editor} useDrawer={false} workspace={workspaceId} />
@@ -0,0 +1,38 @@
<script lang="ts">
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { untrack } from 'svelte'
let {
path,
workspaceId
}: {
/** The schedule this tab edits (the row its location deep-links). */
path: string
/** The session's acting workspace, which the whole trigger subtree reads
* through the `triggerWorkspace` resolver instead of `$workspaceStore`. */
workspaceId: string
} = $props()
// Captured at init, so it must read the current prop rather than close over it.
setTriggerWorkspace(() => workspaceId)
let editor = $state<ScheduleEditorInner | undefined>()
// Load whenever the tab is pointed at another schedule; the component keeps
// its identity across that, as it does for the drawer's row-to-row switch.
// `isFlow` is a first guess only — loadScheduleCfg sets it from the loaded
// config — so the tab needs no knowledge of the target beyond the path.
$effect(() => {
const p = path
const e = editor
if (!p || !e) return
untrack(() => void e.openEdit(p, false))
})
</script>
<div class="h-full w-full overflow-auto p-4">
<!-- useDrawer=false renders the editor as a Section, the same inline form the
script/flow trigger panel mounts (see SchedulePanel). -->
<ScheduleEditorInner bind:this={editor} useDrawer={false} allowDraft showDraftBanner />
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { untrack } from 'svelte'
let {
path,
workspaceId
}: {
/** The variable this tab edits (the row its location deep-links). */
path: string
/** The session's acting workspace, which the editor operates on instead of
* `$workspaceStore` (the nav workspace, which a session leaves put). */
workspaceId: string
} = $props()
let editor = $state<VariableEditor | undefined>()
// Re-selects when the tab is pointed at another variable; the component keeps
// its identity across that, as it does for the drawer's row-to-row switch.
$effect(() => {
const p = path
const e = editor
if (!p || !e) return
untrack(() => e.editVariable(p))
})
</script>
<VariableEditor bind:this={editor} useDrawer={false} workspace={workspaceId} />
@@ -65,8 +65,11 @@ function triggerPages(kind: unknown): string[] {
// 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.
// never match (see the self-sync invariant above). An entity-editor tab does
// match — its location is a list page with the row in the hash, which the path
// comparison drops — but it is in-realm and self-syncing too, so the host's
// `reload` ignores it. 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>
@@ -291,6 +291,32 @@ describe('resolvePreviewTab', () => {
expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' })
})
// The row in the hash is what separates "edit this entity" from "show me the
// list": both are the same page path, and only the first can mount in process.
it('routes an anchored row to its in-process entity editor', () => {
expect(resolvePreviewTab('/schedules#u/me/daily')).toEqual({
kind: 'entity',
entityKind: 'trigger_schedule',
path: 'u/me/daily'
})
expect(resolvePreviewTab('/resources#/resource/u/me/db')).toEqual({
kind: 'entity',
entityKind: 'resource',
path: 'u/me/db'
})
expect(resolvePreviewTab('/variables?owner=u#u/me/token')).toEqual({
kind: 'entity',
entityKind: 'variable',
path: 'u/me/token'
})
})
it('leaves the bare list page, and an anchored page with no hosted editor, on the iframe', () => {
expect(resolvePreviewTab('/schedules')).toEqual({ kind: 'iframe' })
// Anchored like the three above, but no editor is mounted for it yet.
expect(resolvePreviewTab('/kafka_triggers#f/team/ingest')).toEqual({ kind: 'iframe' })
})
it('routes a pipeline folder to the pipeline editor kind', () => {
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({
kind: 'editor',
@@ -116,6 +116,36 @@ export function drawerAnchorFor(location: string): string | undefined {
return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined
}
/** A workspace entity whose editor the preview hosts in process. The
* `UserDraftItemKind` the entity's draft lives under, so the mounted editor and
* the chat address the same cell. */
export type EntityEditorKind = 'trigger_schedule' | 'resource' | 'variable'
// The workspace entities whose single-item editor the preview mounts in process,
// by the list page that deep-links them. Mounting in process is what puts the
// editor in the same realm as the chat: it then holds a live `UserDraft` handle
// on the same cell the chat's writes seed, and reflects them without a reload
// (an iframe has a `UserDraft` of its own, so its only route is `toolReloadEffect`).
// A page absent here keeps loading its whole list in an iframe.
const IN_REALM_ENTITY_PAGES: Partial<Record<string, EntityEditorKind>> = {
[SCHEDULES_PATH]: 'trigger_schedule',
[RESOURCES_PATH]: 'resource',
[VARIABLES_PATH]: 'variable'
}
/** The workspace entity a preview location opens the editor of — a list page
* with a row deep-linked in its hash, for the pages whose editor mounts in
* process. Undefined for the bare list page (an iframe of the list) and for
* anchored pages whose editor is not hosted yet. */
export function parseEntityEditorRoute(
location: string
): { entityKind: EntityEditorKind; path: string } | undefined {
const entityKind = IN_REALM_ENTITY_PAGES[stripBase(location)]
if (!entityKind) return undefined
const path = drawerAnchorFor(location)
return path ? { entityKind, path } : undefined
}
// 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
@@ -467,6 +497,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'entity'; entityKind: EntityEditorKind; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'iframe' }
@@ -477,6 +508,10 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
// Before the item route: an entity location is a list page (never an item
// route) with a row in its hash, so the parse below would call it an iframe.
const entity = parseEntityEditorRoute(url)
if (entity) return { kind: 'entity', ...entity }
const route = parsePreviewItemRoute(url)
if (!route) return { kind: 'iframe' }
const editorKind: SessionTargetKind | undefined =
@@ -63,7 +63,12 @@
onConfigChange = undefined,
onDelete = undefined,
onReset = undefined,
trigger = undefined
trigger = undefined,
// The drawer carries the draft banner in its own slot; inline (`useDrawer`
// false) it is the host's, since the trigger panel inside a script or flow
// editor is covered by that editor's banner. A host that stands alone — a
// session's schedule tab — opts in, or nothing says an edit is unsaved.
showDraftBanner = false
} = $props()
let optionTabSelected:
@@ -1444,6 +1449,16 @@
{@render saveButton()}
</div>
{/snippet}
{#if showDraftBanner}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/if}
{#if docDescription}
{@render docDescription()}
{/if}
@@ -61,6 +61,7 @@
matchPreviewPage,
pageKey,
parseArtifactRoute,
parseEntityEditorRoute,
parsePreviewItemRoute,
previewLocationLabel,
type PreviewTarget
@@ -726,10 +727,21 @@
return (
tab.friendlyLabel ??
(listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ??
// An entity tab hosts one item's editor; `previewLocationLabel` would name
// it after the list page its location shares a path with, so several open
// at once would all read "Schedules". (Not moved into that function: the
// chat's location context reports the page and the anchored row as
// separate fields, and reads the page name from there.)
entityTabLabel(tab.loc) ??
previewLocationLabel(tab.loc)
)
}
function entityTabLabel(loc: string): string | undefined {
const entity = parseEntityEditorRoute(loc)
return entity ? (entity.path.split('/').pop() ?? entity.path) : undefined
}
// Hover title for a tab. A summary label is free text the strip truncates, and
// it hides the path entirely, so the tooltip carries both. The path shown is
// the item's staged one when it has one — a draft's `…/draft_<uuid>` storage