diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte
index 4dc9cebea9..4939a1ef20 100644
--- a/frontend/src/lib/components/ResourceEditorDrawer.svelte
+++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte
@@ -30,9 +30,10 @@
workspace?: string
disableChatOffset?: boolean
onRestored?: () => void
- /** Fires after Save has written, for a caller showing state derived from the
- * resource — `onRestored` only covers restoring an old version. */
- onSaved?: () => void
+ /** Fires after Save has written, with the path it wrote to — which is not the
+ * one it was opened on when the user renamed it. For a caller showing state
+ * derived from the resource; `onRestored` only covers restoring a version. */
+ onSaved?: (savedPath?: string) => 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
@@ -64,6 +65,8 @@
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
let canWriteSelected = $state(true)
+ // The path as edited in the form, which a rename moves off `path`.
+ let livePath: string | undefined = $state(undefined)
let path: string | undefined = $state(undefined)
let selected: string | undefined = $state(undefined)
@@ -153,6 +156,7 @@
bind:canSave
bind:selected
bind:viewJsonSchema
+ onChange={(e) => (livePath = e.path)}
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
@@ -204,7 +208,13 @@
const saved = resourceEditor?.save()
drawer?.closeDrawer()
await saved
- onSaved?.()
+ // Rendered inline there is no drawer to close, so the mounted editor would
+ // otherwise keep the pre-save baseline. Follow a rename before remounting,
+ // or it comes back up on a path the save just moved the item off.
+ const savedPath = livePath ?? path
+ if (savedPath) path = savedPath
+ editorGeneration++
+ onSaved?.(savedPath)
}}
disabled={!canSave}
>
diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte
index ca69385a40..8b0d78800b 100644
--- a/frontend/src/lib/components/VariableEditor.svelte
+++ b/frontend/src/lib/components/VariableEditor.svelte
@@ -42,7 +42,8 @@
let {
workspace = undefined,
useDrawer = true,
- onBack = undefined
+ onBack = undefined,
+ onSaved = undefined
}: {
workspace?: string
/**
@@ -55,6 +56,9 @@
/** Inline only: offered in the header when the host replaced something the
* user should be able to get back to (a session tab that took over the list). */
onBack?: () => void
+ /** Fires after a save, with the path it wrote to — which is not the one it was
+ * opened on when the user renamed it. */
+ onSaved?: (savedPath?: string) => void
} = $props()
let curWs = $derived(workspace ?? $workspaceStore)
@@ -316,7 +320,12 @@
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
+ // A rename moved the item; the drawer host closes over it, but an inline one
+ // stays mounted, so follow the new path here and tell the host about it.
+ const savedPath = current?.path ?? editPath
+ if (savedPath && savedPath !== editPath) editPath = savedPath
drawer?.closeDrawer()
+ onSaved?.(savedPath)
} catch (err) {
sendUserToast(`Could not save variable: ${err.body}`, true)
}
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
index a029b34caa..b0ae98ecf9 100644
--- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte
+++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
@@ -12,8 +12,9 @@
import type { SessionRuntime } from './sessionRuntime.svelte'
import { Loader2 } from 'lucide-svelte'
import {
+ entityEditorHref,
+ entityListHref,
entityListPage,
- pageHref,
resolvePreviewTab,
parsePreviewItemRoute,
parsePreviewSelectedId,
@@ -87,7 +88,11 @@
// Bumped to remount a hosted entity editor, which is how it re-reads the item
// from the server: its own state is built at mount from the draft cell, so
- // there is nothing to refresh in place once that cell has been dropped.
+ // there is nothing to refresh in place once that cell has been dropped. The
+ // acting workspace joins it in the key for the same reason — these editors
+ // load and save against the workspace they were mounted with, so a session
+ // that rescopes (a staged fork materialising on first send) must not leave
+ // them bound to the previous one.
let entityNonce = $state(0)
// Pages whose theme we mirror on live toggles. Regular apps are the only item
@@ -167,14 +172,27 @@
const page = entityListPage(slot.entityKind)
if (page)
runtime.previewTabs.navigate({
+ // The tab's own location, not the page's bare path: it carries the
+ // query of the list the row was opened from, and returning to an
+ // unfiltered list is not returning to where the tab came from.
type: 'page',
- href: pageHref(page.path),
+ href: entityListHref(whereIs(tab)),
label: page.label
})
}
: undefined
)
+ // Follow a rename: the editor stays mounted and keeps editing the item, but the
+ // tab, its label, the chat's ACTIVE PREVIEW and the draft key all address it by
+ // path — so they have to move with it, or they name an item that no longer exists.
+ const retargetTo = $derived(
+ slot.kind === 'entity' && runtime
+ ? (newPath: string) =>
+ runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath))
+ : undefined
+ )
+
// Overlays a tab opens (drawers, modals, popovers) anchor here rather than to the
// document, so they stay within this tab and hide with it when another tab takes over.
// Every branch that renders content in-realm must bind this — an unbound host makes the
@@ -354,7 +372,7 @@
- {#key entityNonce}
+ {#key `${workspaceId}#${entityNonce}`}
{#if slot.entityKind === 'trigger_schedule'}
{#await import('./ScheduleEditorView.svelte')}
{@render editorLoading()}
@@ -365,13 +383,23 @@
{#await import('./ResourceEditorView.svelte')}
{@render editorLoading()}
{:then Module}
-
+
{/await}
- {:else}
+ {:else if slot.entityKind === 'variable'}
{#await import('./VariableEditorView.svelte')}
{@render editorLoading()}
{:then Module}
-
+
{/await}
{/if}
{/key}
diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte
index 7aeec5932e..12c5720766 100644
--- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte
+++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte
@@ -5,7 +5,8 @@
let {
path,
workspaceId,
- onBack
+ onBack,
+ onRenamed
}: {
/** The resource this tab edits (the row its location deep-links). */
path: string
@@ -14,6 +15,10 @@
workspaceId: string
/** Back to the list this editor was reached through; the tab replaced it. */
onBack?: () => void
+ /** The item was saved under a different path. The tab addresses it by path —
+ * as do its label, the chat's ACTIVE PREVIEW and the draft key — so the tab
+ * has to follow, or all four keep naming an item that no longer exists. */
+ onRenamed?: (newPath: string) => void
} = $props()
let editor = $state()
@@ -28,4 +33,12 @@
})
-
+ {
+ if (saved && saved !== path) onRenamed?.(saved)
+ }}
+/>
diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte
index e70e205a06..f221d63ec0 100644
--- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte
+++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte
@@ -24,6 +24,12 @@
let editor = $state()
+ // A save leaves the mounted editor holding the pre-save config as its deployed
+ // baseline — the drawer hides that by closing, which inline is a no-op, so the
+ // banner would go on claiming unsaved changes and Discard would restore the
+ // value the save just replaced. Remounting re-reads the saved schedule.
+ let generation = $state(0)
+
// 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
@@ -31,6 +37,9 @@
$effect(() => {
const p = path
const e = editor
+ // `generation` is tracked so a remount re-opens: `editor` is rebound to the
+ // fresh instance, but reading it alone would not say the instance changed.
+ generation
if (!p || !e) return
untrack(() => void e.openEdit(p, false))
})
@@ -40,21 +49,33 @@
-
- {#snippet customLabel()}
-
+ {/snippet}
+
+ {/key}
diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte
index b2e9071abe..b6397cecc6 100644
--- a/frontend/src/lib/components/sessions/VariableEditorView.svelte
+++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte
@@ -5,7 +5,8 @@
let {
path,
workspaceId,
- onBack
+ onBack,
+ onRenamed
}: {
/** The variable this tab edits (the row its location deep-links). */
path: string
@@ -14,6 +15,10 @@
workspaceId: string
/** Back to the list this editor was reached through; the tab replaced it. */
onBack?: () => void
+ /** The item was saved under a different path. The tab addresses it by path —
+ * as do its label, the chat's ACTIVE PREVIEW and the draft key — so the tab
+ * has to follow, or all four keep naming an item that no longer exists. */
+ onRenamed?: (newPath: string) => void
} = $props()
let editor = $state()
@@ -28,4 +33,12 @@
})
-
+ {
+ if (saved && saved !== path) onRenamed?.(saved)
+ }}
+/>
diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts
index ca7379be20..0a3e7435ca 100644
--- a/frontend/src/lib/components/sessions/pageDrawerSession.ts
+++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts
@@ -11,6 +11,7 @@ import type { UserDraftItemKind } from '$lib/gen'
// flow editors, where pulling the filter schemas that module reads views from would make
// every trigger's save utils eager.
import {
+ drawerHashFor,
pageHref,
stripBase,
TRIGGER_PAGES,
@@ -63,11 +64,6 @@ async function flushOrRefuse(query: Parameters[0
}
}
-// How each page addresses a row in its hash. Resources route theirs through an extra
-// segment; every other page names the path directly.
-const drawerHashFor = (pagePath: string, itemPath: string) =>
- pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath
-
/**
* Deep-link the row whose drawer just opened, so the location says what is on screen — a
* drawer opened from a row's Edit button is as open as one reached by link, and the chat
diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts
index 8d10dc1a32..cb35a3e444 100644
--- a/frontend/src/lib/components/sessions/previewPaths.ts
+++ b/frontend/src/lib/components/sessions/previewPaths.ts
@@ -65,6 +65,13 @@ export function stripBase(path: string): string {
return p || '/'
}
+/** How a list page addresses one of its rows in the hash. Resources route theirs
+ * through an extra segment; every other page names the path directly. The inverse
+ * of `drawerAnchorFor`. */
+export function drawerHashFor(pagePath: string, itemPath: string): string {
+ return pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath
+}
+
export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string }
// Parse a preview URL/pathname into the workspace item it edits, or null for a
diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts
index 1847f8d6cb..83be531bc4 100644
--- a/frontend/src/lib/components/sessions/previewReload.ts
+++ b/frontend/src/lib/components/sessions/previewReload.ts
@@ -120,13 +120,12 @@ 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). 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.
+// The open tabs a round touched: those whose observed page path is in `pages`.
+// Item-editor and pipeline routes are never list pages, so they never match. An
+// entity-editor tab does — its location is a list page with the row in the hash,
+// which the path comparison drops — and the caller tells the two apart, reloading
+// the list frames and passing the rest through `entityEffectForTab`. Pure over a
+// tab snapshot so the sessions page can act 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 9af1685b81..2720dadb43 100644
--- a/frontend/src/lib/components/sessions/previewRouter.test.ts
+++ b/frontend/src/lib/components/sessions/previewRouter.test.ts
@@ -12,7 +12,9 @@ import {
parsePreviewSelectedId,
previewLocationContext,
previewLocationLabel,
- resolvePreviewTab
+ resolvePreviewTab,
+ entityListHref,
+ entityEditorHref
} from './previewRouter'
describe('drawerAnchorFor', () => {
@@ -417,3 +419,22 @@ describe('artifact route', () => {
expect(previewLocationLabel('artifact:abc')).toBe('Artifact')
})
})
+
+describe('entity location helpers', () => {
+ // The list a row was opened from is part of where the tab came from: dropping
+ // its filters on the way back (or on a delete) lands the user on a list they
+ // never chose.
+ it('keeps the list query when going back and when following a rename', () => {
+ const loc = '/resources?filter_path_of=db#/resource/u/me/a'
+ expect(entityListHref(loc)).toBe('/resources?filter_path_of=db')
+ expect(entityEditorHref(loc, 'u/me/b')).toBe(
+ '/resources?filter_path_of=db#/resource/u/me/b'
+ )
+ })
+
+ it('addresses a row the way its own page does', () => {
+ // Resources route theirs through an extra segment; the others name the path.
+ expect(entityEditorHref('/schedules#u/me/a', 'u/me/b')).toBe('/schedules#u/me/b')
+ expect(entityEditorHref('/variables#u/me/a', 'u/me/b')).toBe('/variables#u/me/b')
+ })
+})
diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts
index 581bcd6edf..2c7cbd8635 100644
--- a/frontend/src/lib/components/sessions/previewRouter.ts
+++ b/frontend/src/lib/components/sessions/previewRouter.ts
@@ -1,6 +1,7 @@
import {
ASSETS_PATH,
AUDIT_LOGS_PATH,
+ drawerHashFor,
FOLDERS_PATH,
GROUPS_PATH,
pageKey,
@@ -133,6 +134,21 @@ const IN_REALM_ENTITY_PAGES: Partial> = {
[VARIABLES_PATH]: 'variable'
}
+/** The list an entity editor's tab came from: its own location with the row
+ * dropped. Built from the location rather than the page's bare path so the query
+ * survives — a row opened from a filtered list returns to that same filtered
+ * list, whether by Back or because the item was deleted. */
+export function entityListHref(location: string): string {
+ return location.split('#')[0]
+}
+
+/** The location of `path`'s editor on the list `location` came from, keeping that
+ * list's query. For following a rename: the tab has to name the item it now
+ * shows, or the chat's "edit this" and the draft key still point at the old one. */
+export function entityEditorHref(location: string, path: string): string {
+ return `${entityListHref(location)}#${drawerHashFor(stripBase(location), path)}`
+}
+
/** The list page an entity editor was reached through, so its host can offer the
* way back. Undefined only if a kind is registered above without a curated page,
* which `PREVIEW_PAGES` covers for all three today. */
diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts
index c36a6f8445..83cf2b8a1e 100644
--- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts
+++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts
@@ -352,6 +352,19 @@ describe('SessionPreviewTabs.open', () => {
// A legacy app owns its own hash (the editor reads it as `context.hash`), so the
// observer records app state into `loc`. Reading that as a drawer anchor would
// retarget on reopen, and a same-document retarget forces a reload that discards
+ // the state the user was looking at.
+ it('focuses a legacy app whose own hash changed instead of reloading it', () => {
+ const o = owner()
+ const app = () => ({ type: 'page' as const, href: '/apps/edit/u/me/dash', label: 'dash' })
+ o.open(app())
+ const id = o.tabs[0].id
+ o.observeLocation(id, '/apps/edit/u/me/dash#tab=2')
+
+ expect(o.open(app()).status).toBe('focused')
+ expect(o.tabs).toHaveLength(1)
+ expect(o.tabs[0].url).toBe('/apps/edit/u/me/dash')
+ })
+
// Opening a row inside the list frame is the user asking to edit that entity. The
// frame's copy of the editor is a realm apart from the chat's writes, so the tab
// follows onto the hosted one, which shares the draft cell those writes land in.
@@ -380,19 +393,6 @@ describe('SessionPreviewTabs.open', () => {
expect(o.tabs[0].url).toBe('/kafka_triggers')
})
- // the state the user was looking at.
- it('focuses a legacy app whose own hash changed instead of reloading it', () => {
- const o = owner()
- const app = () => ({ type: 'page' as const, href: '/apps/edit/u/me/dash', label: 'dash' })
- o.open(app())
- const id = o.tabs[0].id
- o.observeLocation(id, '/apps/edit/u/me/dash#tab=2')
-
- expect(o.open(app()).status).toBe('focused')
- expect(o.tabs).toHaveLength(1)
- 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', () => {
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
index 231ab4ca9e..ed070d6c21 100644
--- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
@@ -61,8 +61,7 @@
matchPreviewPage,
pageKey,
parseArtifactRoute,
- entityListPage,
- pageHref,
+ entityListHref,
parseEntityEditorRoute,
parsePreviewItemRoute,
previewLocationLabel,
@@ -593,9 +592,9 @@
: 'none'
// Deletion is re-pointed on the tab model, so a tab whose host is not
// mounted is not left sitting on an item that no longer exists.
+ // The tab's own location, so the list it came from keeps its filters.
if (effect === 'close') {
- const page = entityListPage(entity.entityKind)
- if (page) owner.retargetTabTo(tab.id, pageHref(page.path))
+ owner.retargetTabTo(tab.id, entityListHref(whereIs(tab)))
} else if (effect === 'refresh' && mountedTabKeys.has(key)) {
tabHosts[key]?.reload({ entity: 'refresh' })
}