From cc8d55eb5779d276459b61ddc849a636851a421e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 14:10:40 +0200 Subject: [PATCH] feat(sessions): host schedule, resource and variable editors in process Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 213 +++++++++++------- .../src/lib/components/VariableEditor.svelte | 179 +++++++++------ .../components/sessions/PreviewTabHost.svelte | 34 ++- .../sessions/ResourceEditorView.svelte | 28 +++ .../sessions/ScheduleEditorView.svelte | 38 ++++ .../sessions/VariableEditorView.svelte | 28 +++ .../lib/components/sessions/previewReload.ts | 7 +- .../components/sessions/previewRouter.test.ts | 26 +++ .../lib/components/sessions/previewRouter.ts | 35 +++ .../schedules/ScheduleEditorInner.svelte | 17 +- .../(root)/(logged)/sessions/+page.svelte | 12 + 11 files changed, 459 insertions(+), 158 deletions(-) create mode 100644 frontend/src/lib/components/sessions/ResourceEditorView.svelte create mode 100644 frontend/src/lib/components/sessions/ScheduleEditorView.svelte create mode 100644 frontend/src/lib/components/sessions/VariableEditorView.svelte diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 6b54642379..61bc30d618 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -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 @@ ) - { - if (keepAnchorOnClose) { - keepAnchorOnClose = false - return - } - clearPageDrawerAnchor(RESOURCES_PATH) - }} -> - + {:then Module} + (hasLocalDraft = v)} + onCanWriteChange={(v) => (canWriteSelected = v)} + /> + {/await} +{/snippet} + +{#snippet draftBanner()} + resourceEditor?.localDraftDeployed()} + getCurrent={() => resourceEditor?.localDraftCurrent()} + onDiscard={() => resourceEditor?.discardLocalDraft()} + disabled={!canWriteSelected} + /> +{/snippet} + +{#snippet editorActions()} + + {#if useDrawer} + + {/if} + {#if mode == 'edit' && path && effectiveWorkspace} + + + {/if} + - - {/if} - +{/snippet} + +{#if useDrawer} + { + if (keepAnchorOnClose) { + keepAnchorOnClose = false + return + } + clearPageDrawerAnchor(RESOURCES_PATH) + }} + > + + {#snippet titleExtra()} + {#if mode == 'new' && resource_type} + + {/if} + {/snippet} + {@render editorBody()} + {#snippet banner()} + {@render draftBanner()} + {/snippet} + {#snippet actions()} + {@render editorActions()} + {/snippet} + + +{:else} +
+
+ {mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)} - Save - - {/snippet} - - +
+ {@render editorActions()} +
+
+ {@render draftBanner()} +
+ {@render editorBody()} +
+
+{/if} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 14082bb901..277df7f44f 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 @@ } - clearPageDrawerAnchor(VARIABLES_PATH)}> - (selected ? initialStates[selected] : undefined)} + getCurrent={() => current} + onDiscard={() => { + if (!selected) return + UserDraft.discard('variable', editPath ?? '', initialStates[selected], { + workspace: selected + }) + }} + disabled={!can_write} + /> +{/snippet} + +{#snippet editorActions()} + + {#if useDrawer} + + {/if} + {#if edit && curWs} + + {/if} + +{/snippet} - {#if otherDirty.length > 0} - - You are going to edit the value in: {otherDirty.join(', ')} - - {/if} +{#snippet editorBody()} +
+ {#if !can_write} + + You only have read access to this resource and cannot edit it + + {/if} - {#if current} - {#key current} - - {/key} - {/if} -
- {#snippet actions()} - - {#if edit && curWs} - - {/if} - - {/snippet} -
-
+
+ {@render editorActions()} +
+ + {@render draftBanner()} +
+ {@render editorBody()} +
+ +{/if} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index bf26605ac2..9574a1aaef 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -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} +{:else if slot.kind === 'entity' && mounted} +
+ + {#if slot.entityKind === 'trigger_schedule'} + {#await import('./ScheduleEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {:else if slot.entityKind === 'resource'} + {#await import('./ResourceEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {:else} + {#await import('./VariableEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
{:else if slot.kind === 'artifact' && mounted}
+ 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() + + // 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)) + }) + + + diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte new file mode 100644 index 0000000000..9853c7a76f --- /dev/null +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -0,0 +1,38 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte new file mode 100644 index 0000000000..a9270d4bfb --- /dev/null +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 6e9d750a1f..57a373cf33 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -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 diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index d56c8166dc..9af1685b81 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -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', diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 4651e81b54..d76c179f16 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -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> = { + [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 = diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 7aa83cbbee..1aac6d1076 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -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()}
{/snippet} + {#if showDraftBanner} + draftSync.deployed} + reserveSpace={draftSync.hasBaseline} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/if} {#if docDescription} {@render docDescription()} {/if} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index da8fc31c65..564e80277b 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -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_` storage