From cc8d55eb5779d276459b61ddc849a636851a421e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 14:10:40 +0200 Subject: [PATCH 01/69] 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 From 4d9ec4b81646e79da5cf75b247ac150fe95d970f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 14:57:29 +0200 Subject: [PATCH 02/69] fix(sessions): follow a row opened inside the list frame onto the hosted editor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../sessions/sessionPreviewTabs.svelte.ts | 9 ++++++ .../sessions/sessionPreviewTabs.test.ts | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index f3ca35aab4..23cf60c421 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -7,6 +7,7 @@ import { canonicalizeObservedLoc, describeLocation, matchPreviewPage, + parseEntityEditorRoute, showsView, parseArtifactRoute, parsePipelineRoute, @@ -599,6 +600,14 @@ export class SessionPreviewTabs { const observed = describeLocation(canonical) if (commanded.anchor && !observed.anchor && commanded.identity === observed.identity) { t.url = t.url.split('#')[0] + } else if (!commanded.anchor && observed.anchor && parseEntityEditorRoute(canonical)) { + // The mirror case, for the entities whose editor this panel hosts in + // process: a drawer opened on a row inside the frame is that entity's + // editor, and the frame's copy is a realm apart from the chat's writes — + // it can only be brought up to date by reloading the whole page under it. + // Following the command re-resolves the tab onto the hosted editor, which + // shares the draft cell the chat writes and so tracks it as the user reads. + t.url = canonical } this.#flush() } diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index 096d94a6cc..c36a6f8445 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -9,7 +9,7 @@ import { type PreviewTabsAdapter, type PreviewTabsSnapshot } from './sessionPreviewTabs.svelte' -import { artifactUrl, type PreviewTarget } from './previewRouter' +import { artifactUrl, resolvePreviewTab, type PreviewTarget } from './previewRouter' import type { SessionPreviewTab } from './sessionState.svelte' import { base } from '$lib/base' @@ -352,6 +352,34 @@ 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 + // 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. + it('follows the command onto the hosted editor when a row is opened inside the frame', () => { + const o = owner() + o.open({ type: 'page', href: '/resources', label: 'Resources' }) + const id = o.tabs[0].id + o.observeLocation(id, '/resources#/resource/u/me/db') + + expect(o.tabs[0].url).toBe('/resources#/resource/u/me/db') + expect(resolvePreviewTab(o.tabs[0].url)).toEqual({ + kind: 'entity', + entityKind: 'resource', + path: 'u/me/db' + }) + }) + + // Anchored the same way, but nothing hosts its editor yet: re-commanding would + // point the tab at a row the frame is already showing, for no gain. + it('leaves the command alone for an anchored page with no hosted editor', () => { + const o = owner() + o.open({ type: 'page', href: '/kafka_triggers', label: 'Kafka triggers' }) + const id = o.tabs[0].id + o.observeLocation(id, '/kafka_triggers#f/team/ingest') + + 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() From 1b840dbd7f8974fe4943d9cf7c4b9ef72e506c6d Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 15:26:27 +0200 Subject: [PATCH 03/69] feat(sessions): offer the way back to the list from an entity editor tab Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 26 +++++++++++++---- .../src/lib/components/VariableEditor.svelte | 26 +++++++++++++---- .../components/sessions/PreviewTabHost.svelte | 22 +++++++++++++-- .../sessions/ResourceEditorView.svelte | 7 +++-- .../sessions/ScheduleEditorView.svelte | 28 +++++++++++++++++-- .../sessions/VariableEditorView.svelte | 7 +++-- .../lib/components/sessions/previewRouter.ts | 8 ++++++ 7 files changed, 104 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 61bc30d618..045803ba05 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -3,7 +3,7 @@ import DrawerContent from './common/drawer/DrawerContent.svelte' - import { History, Loader2, Save } from 'lucide-svelte' + import { ArrowLeft, History, Loader2, Save } from 'lucide-svelte' import WsSpecificVersions from './WsSpecificVersions.svelte' import { userStore, workspaceStore } from '$lib/stores' import { isOwner } from '$lib/utils' @@ -24,7 +24,8 @@ disableChatOffset = false, onRestored = undefined, onSaved = undefined, - useDrawer = true + useDrawer = true, + onBack = undefined }: { workspace?: string disableChatOffset?: boolean @@ -39,6 +40,9 @@ * drawer to open, so it simply takes effect. */ useDrawer?: boolean + /** 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 } = $props() let drawer: Drawer | undefined = $state() @@ -233,9 +237,21 @@ {:else}
- {mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)} +
+ {#if onBack} +
{@render editorActions()}
diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 277df7f44f..7bb54c4597 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -15,7 +15,7 @@ import Alert from './common/alert/Alert.svelte' import { sendUserToast } from '$lib/toast' import { canWrite } from '$lib/utils' - import { Save } from 'lucide-svelte' + import { ArrowLeft, Save } from 'lucide-svelte' import VariableForm from './VariableForm.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' import WsSpecificVersions from './WsSpecificVersions.svelte' @@ -41,7 +41,8 @@ // rather than in the navigation workspace. Defaults to $workspaceStore. let { workspace = undefined, - useDrawer = true + useDrawer = true, + onBack = undefined }: { workspace?: string /** @@ -51,6 +52,9 @@ * no drawer to open, so it simply takes effect. */ useDrawer?: boolean + /** 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 } = $props() let curWs = $derived(workspace ?? $workspaceStore) @@ -409,9 +413,21 @@ {:else}
- {edit ? `Update variable at ${initialPath}` : 'Add a variable'} +
+ {#if onBack} +
{@render editorActions()}
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 9574a1aaef..ed438c6133 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -12,6 +12,8 @@ import type { SessionRuntime } from './sessionRuntime.svelte' import { Loader2 } from 'lucide-svelte' import { + entityListPage, + pageHref, resolvePreviewTab, parsePreviewItemRoute, parsePreviewSelectedId, @@ -142,6 +144,20 @@ active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none' ) + // An entity editor replaced the list its tab was opened from (the row is the + // tab now, not a drawer over the list), so it has to offer the way back. + // Re-points this tab rather than opening another: the list is where the tab + // came from, not a second destination. + const backToList = $derived( + slot.kind === 'entity' && runtime + ? () => { + const page = entityListPage(slot.entityKind) + if (page) + runtime.previewTabs.navigate({ type: 'page', href: pageHref(page.path), label: page.label }) + } + : 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 @@ -324,19 +340,19 @@ {#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}
diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 68ba37e910..7aeec5932e 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -4,13 +4,16 @@ let { path, - workspaceId + workspaceId, + onBack }: { /** 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 + /** Back to the list this editor was reached through; the tab replaced it. */ + onBack?: () => void } = $props() let editor = $state() @@ -25,4 +28,4 @@ }) - + diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 9853c7a76f..e70e205a06 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -1,17 +1,22 @@ - + diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index d76c179f16..581bcd6edf 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -133,6 +133,14 @@ const IN_REALM_ENTITY_PAGES: Partial> = { [VARIABLES_PATH]: 'variable' } +/** 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. */ +export function entityListPage(kind: EntityEditorKind): PreviewPage | undefined { + const path = Object.keys(IN_REALM_ENTITY_PAGES).find((p) => IN_REALM_ENTITY_PAGES[p] === kind) + return path ? matchPreviewPage(path) : undefined +} + /** 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 From 9deb95a63e1e40f054683459b7c3280a89298f66 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 18:42:36 +0200 Subject: [PATCH 04/69] fix(sessions): keep hosted entity editors correct across retarget, restore and non-write tools Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 51 ++++++++----- .../src/lib/components/VariableEditor.svelte | 2 +- .../components/sessions/PreviewTabHost.svelte | 72 ++++++++++++------- .../components/sessions/previewReload.test.ts | 22 +++++- .../lib/components/sessions/previewReload.ts | 37 +++++++--- .../(root)/(logged)/sessions/+page.svelte | 23 ++++-- 6 files changed, 146 insertions(+), 61 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 045803ba05..4dc9cebea9 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -47,6 +47,9 @@ let drawer: Drawer | undefined = $state() let historyDrawer: Drawer | undefined = $state() + // Bumped whenever the mounted editor's captured baseline is no longer the + // truth (see editorBody). + let editorGeneration = $state(0) let canSave = $state(true) let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) @@ -131,23 +134,30 @@ {#snippet editorBody()} - {#await import('./ResourceEditor.svelte')} - - {:then Module} - (hasLocalDraft = v)} - onCanWriteChange={(v) => (canWriteSelected = v)} - /> - {/await} + + {#key `${path ?? ''}#${editorGeneration}`} + {#await import('./ResourceEditor.svelte')} + + {:then Module} + (hasLocalDraft = v)} + onCanWriteChange={(v) => (canWriteSelected = v)} + /> + {/await} + {/key} {/snippet} {#snippet draftBanner()} @@ -273,10 +283,13 @@ canClear={canClearSelected} onRestore={() => { historyDrawer?.closeDrawer() - // Close the editor too. It holds a baseline captured before the restore, and + // Drop the editor. It holds a baseline captured before the restore, and // any local draft on top of it, so saving from it afterwards would write the - // pre-restore value straight back over the version just restored. + // pre-restore value straight back over the version just restored. Closing + // the drawer is what does that when there is one; rendered inline there is + // no drawer to close, so remount it onto the restored value instead. drawer?.closeDrawer() + editorGeneration++ // Its own callback rather than the `refresh` event: callers bind that to // reopening a picker (EditorBar), which a restore should not trigger. onRestored?.() diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 7bb54c4597..ca69385a40 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -353,7 +353,7 @@ disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''} startIcon={{ icon: Save }} variant="accent" - size="sm" + unifiedSize="sm" > {edit ? 'Update' : 'Save'} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index ed438c6133..c131369a5e 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -19,6 +19,7 @@ parsePreviewSelectedId, showsView } from './previewRouter' + import type { EntityToolEffect } from './previewReload' import { withMenuHidden } from './sessionMode.svelte' import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte' import { setOverlayHost } from '../common/overlayHost.svelte' @@ -84,6 +85,11 @@ let frame: HTMLIFrameElement | undefined = $state() + // 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. + let entityNonce = $state(0) + // Pages whose theme we mirror on live toggles. Regular apps are the only item // route that resolves to an iframe (scripts/flows/raw apps mount live editors) // and they pin their own theme, so excluding item routes excludes exactly them. @@ -104,14 +110,21 @@ applyPageIframeTheme(darkMode) }) - export function reload() { + export function reload(opts?: { entity?: EntityToolEffect }) { // 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. 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 + // iframe fallback (a separate page) has to be told to refresh. + if (slot.kind === 'editor') return + // An entity editor holds a live UserDraft handle on the cell the chat's + // write seeds, so a write needs nothing from here — but the tools that drop + // that cell go behind it: it has to be re-read from the server, or left + // when the item it edits no longer exists. + if (slot.kind === 'entity') { + if (opts?.entity === 'close') backToList?.() + else if (opts?.entity === 'refresh') entityNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -153,7 +166,11 @@ ? () => { const page = entityListPage(slot.entityKind) if (page) - runtime.previewTabs.navigate({ type: 'page', href: pageHref(page.path), label: page.label }) + runtime.previewTabs.navigate({ + type: 'page', + href: pageHref(page.path), + label: page.label + }) } : undefined ) @@ -335,26 +352,29 @@ aria-hidden={!active} > - {#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} + the runnable pickers and the resource-type schema forms. Keyed on the + refresh nonce so a dropped draft cell remounts the editor (see reload). --> + {#key entityNonce} + {#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} + {/key}
{:else if slot.kind === 'artifact' && mounted}
{ @@ -27,6 +27,26 @@ describe('toolReloadEffect', () => { ).toEqual(['/nats_triggers']) }) + // A hosted entity editor holds the draft cell a write seeds, so a write must + // leave it alone — that is what makes the chat's edit show up live. The tools + // that drop the cell have to reach it, and a delete leaves it with no item. + it('asks a hosted entity editor to hold, refresh, or close, per tool', () => { + expect(toolReloadEffect('write_resource', {}).entity).toBe('none') + expect(toolReloadEffect('write_schedule', { path: 'u/me/s' }).entity).toBe('none') + expect(toolReloadEffect('write_trigger', { kind: 'kafka' }).entity).toBe('none') + expect(toolReloadEffect('deploy_workspace_item', { type: 'resource' }).entity).toBe('refresh') + expect(toolReloadEffect('discard_local_draft', { type: 'variable' }).entity).toBe('refresh') + expect(toolReloadEffect('rebase_draft', { type: 'schedule' }).entity).toBe('refresh') + expect(toolReloadEffect('delete_workspace_item', { type: 'resource' }).entity).toBe('close') + }) + + it('takes the strongest effect across a debounced round', () => { + expect(strongerEntityEffect('none', 'refresh')).toBe('refresh') + expect(strongerEntityEffect('refresh', 'close')).toBe('close') + expect(strongerEntityEffect('close', 'refresh')).toBe('close') + expect(strongerEntityEffect('none', 'none')).toBe('none') + }) + it('reloads no page for item-editor kinds (they self-sync via their live editor)', () => { for (const type of ['script', 'flow', 'app']) { expect(toolReloadEffect('deploy_workspace_item', { type }).pages).toEqual([]) diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 57a373cf33..3c4a9b05d7 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -15,34 +15,53 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' // 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: [] } +/** What a tool asks of a hosted entity editor (see previewRouter's + * `IN_REALM_ENTITY_PAGES`) showing one of the affected pages. A plain write + * reaches it on its own — it holds the draft cell the write seeds — but the + * tools that clear or replace that cell go behind it, and the item can be gone + * altogether. `none` is what makes the live-editing case live. */ +export type EntityToolEffect = 'none' | 'refresh' | 'close' + +export type ToolReloadEffect = { pages: string[]; entity: EntityToolEffect } +const NO_RELOAD: ToolReloadEffect = { pages: [], entity: 'none' } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { switch (name) { case 'write_schedule': - return { pages: ['/schedules'] } + return { pages: ['/schedules'], entity: 'none' } case 'write_trigger': - return { pages: triggerPages(args?.kind) } + return { pages: triggerPages(args?.kind), entity: 'none' } case 'write_resource': - return { pages: ['/resources'] } + return { pages: ['/resources'], entity: 'none' } case 'write_variable': - return { pages: ['/variables'] } + return { pages: ['/variables'], entity: 'none' } case 'create_folder': - return { pages: ['/folders'] } + return { pages: ['/folders'], entity: 'none' } // Generic item tools carry a workspace-item `type`; refresh its list page // when it lives on one (schedule/resource/variable/trigger). script/flow/app // have their own live editor tab and no previewed list page → nothing. - case 'delete_workspace_item': + // These all drop the draft the hosted editor is bound to: deploying or + // discarding replaces it with the deployed value, and deleting removes the + // item, so the editor is re-read from the server or left behind entirely. case 'discard_local_draft': case 'deploy_workspace_item': case 'rebase_draft': - return { pages: pagesForItemType(args?.type, args) } + return { pages: pagesForItemType(args?.type, args), entity: 'refresh' } + case 'delete_workspace_item': + return { pages: pagesForItemType(args?.type, args), entity: 'close' } default: return NO_RELOAD } } +/** The stronger of two effects, for a debounced round that saw several tools: + * a delete outranks a refresh, which outranks nothing. */ +export function strongerEntityEffect(a: EntityToolEffect, b: EntityToolEffect): EntityToolEffect { + if (a === 'close' || b === 'close') return 'close' + if (a === 'refresh' || b === 'refresh') return 'refresh' + return 'none' +} + function pagesForItemType(type: unknown, args: any): string[] { switch (type) { case 'schedule': diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 564e80277b..81c1b63464 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -66,7 +66,12 @@ previewLocationLabel, type PreviewTarget } from '$lib/components/sessions/previewRouter' - import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload' + import { + toolReloadEffect, + tabsToReload, + strongerEntityEffect, + type EntityToolEffect + } from '$lib/components/sessions/previewReload' import { leafKeyFor, loadKind, @@ -557,37 +562,45 @@ // 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() + // What those tools ask of a hosted entity editor on one of those pages — + // carried alongside the paths because the debounce below loses which tool + // contributed which page. + let pendingEntity: EntityToolEffect = 'none' // 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) { + function reloadTabs(pages: Set, entity: EntityToolEffect) { for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner) continue for (const tab of tabsToReload(owner.tabs, pages)) { const key = tabKey(s.id, tab.id) - if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + if (mountedTabKeys.has(key)) tabHosts[key]?.reload({ entity }) } } } function flushReload() { const pages = pendingPages + const entity = pendingEntity pendingPages = new Set() - reloadTabs(pages) + pendingEntity = 'none' + reloadTabs(pages, entity) } $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. setToolCompletionListener((name, args) => { - const { pages } = toolReloadEffect(name, args) + const { pages, entity } = toolReloadEffect(name, args) if (pages.length === 0) return for (const p of pages) pendingPages.add(p) + pendingEntity = strongerEntityEffect(pendingEntity, entity) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) }) return () => { clearTimeout(reloadHandle) pendingPages = new Set() + pendingEntity = 'none' setToolCompletionListener(undefined) } }) From ba1ae123fe38883f4243a2e4cf56b99cce626f94 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 18:57:23 +0200 Subject: [PATCH 05/69] fix(sessions): scope entity-tab effects to the mutated item, tab and workspace Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/copilot/chat/shared.ts | 10 ++-- .../components/sessions/PreviewTabHost.svelte | 8 +-- .../components/sessions/previewReload.test.ts | 53 +++++++++++++++++- .../lib/components/sessions/previewReload.ts | 46 ++++++++++++++-- .../sessions/sessionPreviewTabs.svelte.ts | 12 +++++ .../sessions/sessionRuntime.svelte.ts | 7 ++- .../(root)/(logged)/sessions/+page.svelte | 54 +++++++++++++------ 7 files changed, 161 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 6d6794b56c..8bb263a77b 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -687,10 +687,12 @@ export function pendingUserActionDetail( // the sessions page) react to mutating tools — refreshing previews — without // the tool layer knowing about the UI. Single slot; the consumer filters by name // and reads the tool args (e.g. the mutated item's `path`) to scope its refresh. -let toolCompletionListener: ((toolName: string, args: any) => void) | undefined +let toolCompletionListener: + | ((toolName: string, args: any, workspace: string) => void) + | undefined export function setToolCompletionListener( - fn: ((toolName: string, args: any) => void) | undefined + fn: ((toolName: string, args: any, workspace: string) => void) | undefined ): void { toolCompletionListener = fn } @@ -719,7 +721,9 @@ async function callTool({ ) } const result = await tool.fn({ args, workspace, helpers, toolCallbacks, toolId }) - toolCompletionListener?.(functionName, args) + // The workspace the tool acted on, so a consumer can tell a mutation in this + // session's workspace from the same path in another one. + toolCompletionListener?.(functionName, args, workspace) return result } diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index c131369a5e..a029b34caa 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -118,11 +118,11 @@ if (slot.kind === 'editor') return // An entity editor holds a live UserDraft handle on the cell the chat's // write seeds, so a write needs nothing from here — but the tools that drop - // that cell go behind it: it has to be re-read from the server, or left - // when the item it edits no longer exists. + // that cell go behind it, and it has to be re-read from the server. + // Deletion is not handled here: it belongs to the tab whose item is gone, + // mounted or not, so the page re-points that tab by id instead. if (slot.kind === 'entity') { - if (opts?.entity === 'close') backToList?.() - else if (opts?.entity === 'refresh') entityNonce++ + if (opts?.entity === 'refresh') entityNonce++ return } try { diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index 6a7d6bff88..5e24e12f34 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { toolReloadEffect, tabsToReload, strongerEntityEffect } from './previewReload' +import { + toolReloadEffect, + tabsToReload, + strongerEntityEffect, + entityEffectForTab +} from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' describe('toolReloadEffect', () => { @@ -40,6 +45,14 @@ describe('toolReloadEffect', () => { expect(toolReloadEffect('delete_workspace_item', { type: 'resource' }).entity).toBe('close') }) + it('carries the mutated item path, so an editor on another item is left alone', () => { + expect( + toolReloadEffect('delete_workspace_item', { type: 'resource', path: 'u/me/a' }).path + ).toBe('u/me/a') + // No path in the args: nothing to scope by, so it reaches every editor on the page. + expect(toolReloadEffect('deploy_workspace_item', { type: 'resource' }).path).toBeUndefined() + }) + it('takes the strongest effect across a debounced round', () => { expect(strongerEntityEffect('none', 'refresh')).toBe('refresh') expect(strongerEntityEffect('refresh', 'close')).toBe('close') @@ -72,6 +85,44 @@ describe('toolReloadEffect', () => { }) }) +describe('entityEffectForTab', () => { + const del = (path: string, workspace = 'ws1') => ({ + pages: ['/resources'], + effect: 'close' as const, + path, + workspace + }) + const tab = { listPage: '/resources', path: 'u/me/a', workspace: 'ws1' } + + it('applies a mutation to the editor on that item', () => { + expect(entityEffectForTab([del('u/me/a')], tab)).toBe('close') + }) + + // The bug this guards: deleting one resource must not shut every open resource + // editor, nor the same path in a session acting on another workspace. + it('leaves editors on another item, page, or workspace alone', () => { + expect(entityEffectForTab([del('u/me/b')], tab)).toBe('none') + expect(entityEffectForTab([del('u/me/a', 'ws2')], tab)).toBe('none') + expect(entityEffectForTab([{ ...del('u/me/a'), pages: ['/variables'] }], tab)).toBe('none') + }) + + it('reaches every editor on the page when the tool named no item', () => { + expect(entityEffectForTab([{ ...del('u/me/a'), path: undefined }], tab)).toBe('close') + }) + + it('takes the strongest of the mutations that reach it, not of the whole round', () => { + const refreshOther = { + pages: ['/resources'], + effect: 'refresh' as const, + path: 'u/me/b', + workspace: 'ws1' + } + const refreshMine = { ...refreshOther, path: 'u/me/a' } + expect(entityEffectForTab([refreshMine, del('u/me/b')], tab)).toBe('refresh') + expect(entityEffectForTab([refreshMine, del('u/me/a')], tab)).toBe('close') + }) +}) + describe('tabsToReload', () => { const scheduleTab: SessionPreviewTab = { id: 's', url: '/schedules', loc: '/schedules' } const resourceTab: SessionPreviewTab = { id: 'r', url: '/resources', loc: '/resources' } diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 3c4a9b05d7..1847f8d6cb 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -22,7 +22,14 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' * altogether. `none` is what makes the live-editing case live. */ export type EntityToolEffect = 'none' | 'refresh' | 'close' -export type ToolReloadEffect = { pages: string[]; entity: EntityToolEffect } +export type ToolReloadEffect = { + pages: string[] + entity: EntityToolEffect + /** The item the tool mutated, when its args name one. A hosted editor on + * another path is not affected by it — unlike a list page, which shows every + * row and so reloads for any mutation on it. */ + path?: string +} const NO_RELOAD: ToolReloadEffect = { pages: [], entity: 'none' } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { @@ -46,15 +53,46 @@ 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), entity: 'refresh' } + return { pages: pagesForItemType(args?.type, args), entity: 'refresh', path: itemPath(args) } case 'delete_workspace_item': - return { pages: pagesForItemType(args?.type, args), entity: 'close' } + return { pages: pagesForItemType(args?.type, args), entity: 'close', path: itemPath(args) } default: return NO_RELOAD } } -/** The stronger of two effects, for a debounced round that saw several tools: +function itemPath(args: any): string | undefined { + return typeof args?.path === 'string' && args.path ? args.path : undefined +} + +/** One item mutation from a chat round, as the preview needs to read it back. */ +export type EntityMutation = { + pages: string[] + effect: EntityToolEffect + path?: string + /** The workspace the tool acted on — a session on a fork must not be moved by + * a mutation to the same path in its parent. */ + workspace: string +} + +/** What a hosted entity editor showing `tab` must do about a round's mutations. + * A mutation reaches it only in its own workspace, on its own list page, and — + * when the tool named one — on its own path. */ +export function entityEffectForTab( + mutations: readonly EntityMutation[], + tab: { listPage: string; path: string; workspace: string } +): EntityToolEffect { + let effect: EntityToolEffect = 'none' + for (const m of mutations) { + if (m.workspace !== tab.workspace) continue + if (!m.pages.includes(tab.listPage)) continue + if (m.path !== undefined && m.path !== tab.path) continue + effect = strongerEntityEffect(effect, m.effect) + } + return effect +} + +/** The stronger of two effects, for a tab several of a round's mutations reach: * a delete outranks a refresh, which outranks nothing. */ export function strongerEntityEffect(a: EntityToolEffect, b: EntityToolEffect): EntityToolEffect { if (a === 'close' || b === 'close') return 'close' diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index 23cf60c421..0f454c777c 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -301,6 +301,18 @@ export class SessionPreviewTabs { if ((commandUnchanged && drifted) || fragmentOnly) this.pulseReload(tab.id) } + /** Re-point one tab by id. `navigate` moves whichever tab is active, which is + * what a user's own pick means; this is for a change that belongs to a + * particular tab whether or not the user is looking at it — the item a hosted + * entity editor was showing has been deleted out from under it. Works on a + * tab with no mounted host, which would otherwise never hear about it. */ + retargetTabTo(id: string, url: string): void { + const tab = this.#tabs.find((t) => t.id === id) + if (!tab) return + this.#retarget(tab, url) + this.#flush() + } + // Force the host to reload the iframe. A navigation onto the tab's exact current URL // changes nothing, so URL-driven behavior — a `#` opening a drawer the user has // since closed — would never re-fire. diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index c49a464962..c866f2ce6b 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -389,7 +389,12 @@ 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 + // Entity editors are hosted in process but, unlike the item editors, they + // register no live-editor draft — so this is the only thing that tells the + // model which schedule/resource/variable is on screen, and "change this + // one" would otherwise resolve against nothing. + const kind = resolvePreviewTab(tab.url).kind + if (kind !== 'iframe' && kind !== 'entity') return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 81c1b63464..231ab4ca9e 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -38,7 +38,7 @@ } from '$lib/components/sessions/sessionState.svelte' import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte' import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte' - import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' + import { whereIs, type SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { getOrCreateRuntime, @@ -61,16 +61,19 @@ matchPreviewPage, pageKey, parseArtifactRoute, + entityListPage, + pageHref, parseEntityEditorRoute, parsePreviewItemRoute, previewLocationLabel, + stripBase, type PreviewTarget } from '$lib/components/sessions/previewRouter' import { toolReloadEffect, tabsToReload, - strongerEntityEffect, - type EntityToolEffect + entityEffectForTab, + type EntityMutation } from '$lib/components/sessions/previewReload' import { leafKeyFor, @@ -562,45 +565,64 @@ // 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() - // What those tools ask of a hosted entity editor on one of those pages — - // carried alongside the paths because the debounce below loses which tool - // contributed which page. - let pendingEntity: EntityToolEffect = 'none' + // The same round's mutations, kept whole rather than folded into one verdict: + // a hosted entity editor is only affected by a mutation to its own item, in + // its own workspace, so the path and workspace have to survive the debounce. + let pendingMutations: EntityMutation[] = [] // 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, entity: EntityToolEffect) { + function reloadTabs(pages: Set, mutations: EntityMutation[]) { for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner) continue + const workspace = getEffectiveWorkspaceId(s) for (const tab of tabsToReload(owner.tabs, pages)) { const key = tabKey(s.id, tab.id) - if (mountedTabKeys.has(key)) tabHosts[key]?.reload({ entity }) + // A list tab shows every row, so any mutation on its page is its + // business. A hosted entity tab shows one item, and is told apart here. + const entity = parseEntityEditorRoute(whereIs(tab)) + if (!entity) { + if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + continue + } + const listPage = stripBase(whereIs(tab)) + const effect = workspace + ? entityEffectForTab(mutations, { listPage, path: entity.path, workspace }) + : '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. + if (effect === 'close') { + const page = entityListPage(entity.entityKind) + if (page) owner.retargetTabTo(tab.id, pageHref(page.path)) + } else if (effect === 'refresh' && mountedTabKeys.has(key)) { + tabHosts[key]?.reload({ entity: 'refresh' }) + } } } } function flushReload() { const pages = pendingPages - const entity = pendingEntity + const mutations = pendingMutations pendingPages = new Set() - pendingEntity = 'none' - reloadTabs(pages, entity) + pendingMutations = [] + reloadTabs(pages, mutations) } $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. - setToolCompletionListener((name, args) => { - const { pages, entity } = toolReloadEffect(name, args) + setToolCompletionListener((name, args, workspace) => { + const { pages, entity, path } = toolReloadEffect(name, args) if (pages.length === 0) return for (const p of pages) pendingPages.add(p) - pendingEntity = strongerEntityEffect(pendingEntity, entity) + if (entity !== 'none') pendingMutations.push({ pages, effect: entity, path, workspace }) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) }) return () => { clearTimeout(reloadHandle) pendingPages = new Set() - pendingEntity = 'none' + pendingMutations = [] setToolCompletionListener(undefined) } }) From f79160e5989c3a653f01df2ae20ec85966c9fea9 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 19:24:05 +0200 Subject: [PATCH 06/69] fix(sessions): rebind hosted entity editors on rescope, rename and save Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 18 ++++-- .../src/lib/components/VariableEditor.svelte | 11 +++- .../components/sessions/PreviewTabHost.svelte | 42 +++++++++++--- .../sessions/ResourceEditorView.svelte | 17 +++++- .../sessions/ScheduleEditorView.svelte | 55 +++++++++++++------ .../sessions/VariableEditorView.svelte | 17 +++++- .../components/sessions/pageDrawerSession.ts | 6 +- .../lib/components/sessions/previewPaths.ts | 7 +++ .../lib/components/sessions/previewReload.ts | 13 ++--- .../components/sessions/previewRouter.test.ts | 23 +++++++- .../lib/components/sessions/previewRouter.ts | 16 ++++++ .../sessions/sessionPreviewTabs.test.ts | 26 ++++----- .../(root)/(logged)/sessions/+page.svelte | 7 +-- 13 files changed, 195 insertions(+), 63 deletions(-) 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()} -
- {#if onBack} -
- {/snippet} -
+ {#key generation} + + generation++} + > + {#snippet customLabel()} +
+ {#if onBack} +
+ {/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' }) } From f154863061806bf9e3182c41f2bdca0179b8e76d Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 19:49:33 +0200 Subject: [PATCH 07/69] fix(sessions): keep the tab put when a resource save fails Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditorDrawer.svelte | 10 +++++++--- .../src/lib/components/sessions/PreviewTabHost.svelte | 10 +++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 4939a1ef20..795a413cbd 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -57,7 +57,8 @@ let resourceEditor: | { - save: () => void + /** False when the write failed; it toasts its own error. */ + save: () => Promise localDraftDeployed: () => unknown localDraftCurrent: () => unknown discardLocalDraft: () => void @@ -205,9 +206,12 @@ // 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() + const saving = resourceEditor?.save() drawer?.closeDrawer() - await saved + // Everything below moves this host onto the path that was written, so it + // must not run for a write that failed — a rejected rename (a name + // collision, say) would point the tab at a path this save never created. + if (!(await saving)) return // 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. diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index b0ae98ecf9..a44f64de1b 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -86,13 +86,9 @@ let frame: HTMLIFrameElement | undefined = $state() - // 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. 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. + // A hosted entity editor builds its state at mount, from the draft cell and the + // workspace it was given, and can refresh neither in place — so both the bump + // below and a change of `workspaceId` key it, and remounting is how it re-reads. let entityNonce = $state(0) // Pages whose theme we mirror on live toggles. Regular apps are the only item From a939a12d1121b879715baab51a4820c2fec9f753 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 20:07:49 +0200 Subject: [PATCH 08/69] fix(sessions): refresh a hosted editor a write could not reach Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/sessions/previewReload.test.ts | 18 ++++++++++++++- .../lib/components/sessions/previewReload.ts | 22 +++++++++++++++---- .../lib/components/sessions/previewRouter.ts | 6 +++++ frontend/src/lib/userDraft.svelte.ts | 10 +++++++++ .../(root)/(logged)/sessions/+page.svelte | 16 ++++++++++++-- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index 5e24e12f34..db4f0de977 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -3,7 +3,8 @@ import { toolReloadEffect, tabsToReload, strongerEntityEffect, - entityEffectForTab + entityEffectForTab, + effectForWrite } from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' @@ -85,6 +86,21 @@ describe('toolReloadEffect', () => { }) }) +describe('effectForWrite', () => { + // A write is normally invisible to this layer — the editor holds the cell it + // seeds. The exception is an editor whose first load is still in flight: it + // holds no cell yet, `seed` no-ops, and only a re-read reconciles it. + it('asks for a refresh only when the seed found no live editor', () => { + expect(effectForWrite('none', true)).toBe('none') + expect(effectForWrite('none', false)).toBe('refresh') + }) + + it('never weakens what the tool already asked for', () => { + expect(effectForWrite('close', false)).toBe('close') + expect(effectForWrite('refresh', true)).toBe('refresh') + }) +}) + describe('entityEffectForTab', () => { const del = (path: string, workspace = 'ws1') => ({ pages: ['/resources'], diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 83be531bc4..2e84eed59a 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -34,14 +34,16 @@ const NO_RELOAD: ToolReloadEffect = { pages: [], entity: 'none' } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { switch (name) { + // `path` rides along on the writes too: it is what scopes the refresh a write + // needs when its seed missed a still-loading editor (see effectForWrite). case 'write_schedule': - return { pages: ['/schedules'], entity: 'none' } + return { pages: ['/schedules'], entity: 'none', path: itemPath(args) } case 'write_trigger': - return { pages: triggerPages(args?.kind), entity: 'none' } + return { pages: triggerPages(args?.kind), entity: 'none', path: itemPath(args) } case 'write_resource': - return { pages: ['/resources'], entity: 'none' } + return { pages: ['/resources'], entity: 'none', path: itemPath(args) } case 'write_variable': - return { pages: ['/variables'], entity: 'none' } + return { pages: ['/variables'], entity: 'none', path: itemPath(args) } case 'create_folder': return { pages: ['/folders'], entity: 'none' } // Generic item tools carry a workspace-item `type`; refresh its list page @@ -92,6 +94,18 @@ export function entityEffectForTab( return effect } +/** What a write actually asks of a hosted editor. A write normally needs nothing + * — the editor holds the draft cell it seeds — but `UserDraft.seed` no-ops when + * no editor holds that cell yet, which is the case while one is still loading. + * The write is then only on the server, and the editor has to re-read it: its own + * loader fetches the draft, so a refresh reconciles it. */ +export function effectForWrite( + effect: EntityToolEffect, + seedReachedEditor: boolean +): EntityToolEffect { + return effect === 'none' && !seedReachedEditor ? 'refresh' : effect +} + /** The stronger of two effects, for a tab several of a round's mutations reach: * a delete outranks a refresh, which outranks nothing. */ export function strongerEntityEffect(a: EntityToolEffect, b: EntityToolEffect): EntityToolEffect { diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 2c7cbd8635..652c6e38c8 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -134,6 +134,12 @@ const IN_REALM_ENTITY_PAGES: Partial> = { [VARIABLES_PATH]: 'variable' } +/** The entity kind a list page hosts an editor for, or undefined for a page with + * none. The kind doubles as the `UserDraftItemKind` its draft lives under. */ +export function entityKindForPage(pagePath: string): EntityEditorKind | undefined { + return IN_REALM_ENTITY_PAGES[pagePath] +} + /** 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 diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 24572ee1ab..03ad738758 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -397,6 +397,16 @@ export const UserDraft = { * * No-op if the entry isn't live yet (acquire via `use`/`useMany` first). */ + /** + * Whether a mounted editor currently holds this cell — i.e. whether {@link seed} + * would reach one rather than no-op. An editor acquires its cell only once its + * first load resolves, so this is false while one is still loading, and a + * caller that seeded then has to reconcile the editor some other way. + */ + hasLiveEntry(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { + return entries.has(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + seed(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index ed070d6c21..c52f9f3ac9 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -38,7 +38,10 @@ } from '$lib/components/sessions/sessionState.svelte' import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte' import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte' - import { whereIs, type SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' + import { + whereIs, + type SessionPreviewTabs + } from '$lib/components/sessions/sessionPreviewTabs.svelte' import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { getOrCreateRuntime, @@ -61,6 +64,7 @@ matchPreviewPage, pageKey, parseArtifactRoute, + entityKindForPage, entityListHref, parseEntityEditorRoute, parsePreviewItemRoute, @@ -72,6 +76,7 @@ toolReloadEffect, tabsToReload, entityEffectForTab, + effectForWrite, type EntityMutation } from '$lib/components/sessions/previewReload' import { @@ -81,6 +86,7 @@ type WorkspaceItemKind } from '$lib/components/workspacePicker' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' + import { UserDraft } from '$lib/userDraft.svelte' const globalEnabled = isGlobalAiEnabled() @@ -614,7 +620,13 @@ const { pages, entity, path } = toolReloadEffect(name, args) if (pages.length === 0) return for (const p of pages) pendingPages.add(p) - if (entity !== 'none') pendingMutations.push({ pages, effect: entity, path, workspace }) + // A write reaches a hosted editor through the draft cell it holds, but an + // editor still loading holds none yet and `seed` no-opped past it — so ask + // whether the cell was live, and refresh the ones the write missed. + const kind = path ? entityKindForPage(pages[0]) : undefined + const seedReachedEditor = !kind || !path || UserDraft.hasLiveEntry(kind, path, { workspace }) + const effect = effectForWrite(entity, seedReachedEditor) + if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace }) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) }) From 82cf51960639cebc819ec017dae9f69f09895ba7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 20:25:15 +0200 Subject: [PATCH 09/69] fix(sessions): record a missed seed when it happens, not after the write Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../lib/components/sessions/previewReload.ts | 5 ++- frontend/src/lib/userDraft.svelte.ts | 35 ++++++++++++------- .../(root)/(logged)/sessions/+page.svelte | 7 ++-- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 2e84eed59a..69cac9c5b3 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -64,7 +64,10 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { } function itemPath(args: any): string | undefined { - return typeof args?.path === 'string' && args.path ? args.path : undefined + // write_trigger carries the item under `config` (see writeTriggerDraft), unlike + // every other tool here, which names it at the top level. + const p = args?.path ?? args?.config?.path + return typeof p === 'string' && p ? p : undefined } /** One item mutation from a chat round, as the preview needs to read it back. */ diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 03ad738758..b2699818da 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -124,6 +124,9 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & { } const entries = new Map() +// Cells whose last `seed` found no live entry (see `takeSeedMiss`). Bounded by +// the seeds that missed and not yet been read back, and each entry is one key. +const seedMisses = new Set() const liveEditorDrafts = new Map() /** * Map keys whose entry should start `syncSuspended` on acquire. Lets @@ -395,27 +398,35 @@ export const UserDraft = { * is still needed when a write fans out across components (e.g. an * editor's `initContent` cascading into the bound value). * - * No-op if the entry isn't live yet (acquire via `use`/`useMany` first). + * No-op if the entry isn't live yet (acquire via `use`/`useMany` first) — + * recorded for {@link takeSeedMiss}, since the value then reached the server + * without reaching the editor that will show it. */ - /** - * Whether a mounted editor currently holds this cell — i.e. whether {@link seed} - * would reach one rather than no-op. An editor acquires its cell only once its - * first load resolves, so this is false while one is still loading, and a - * caller that seeded then has to reconcile the editor some other way. - */ - hasLiveEntry(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { - return entries.has(mapKey(resolveWorkspace(opts), itemKind, path)) - }, - seed(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) - if (!entry) return + if (!entry) { + seedMisses.add(mk) + return + } + seedMisses.delete(mk) entry.seedNextWrite = true entry.state.val = snapshotDraftValue(value) }, + /** + * Whether the last {@link seed} for this cell found no live entry, clearing the + * record. An editor acquires its cell only once its first load resolves, so a + * seed during that window reaches nothing and the value lands on the server + * alone — a caller showing that editor has to make it re-read. Recorded at the + * seed rather than asked afterwards: by then the editor may have acquired the + * cell, hiding the very miss this reports. + */ + takeSeedMiss(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { + return seedMisses.delete(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + /** * Currently-mounted live entries for `workspace` (in-tab only — for a * workspace-wide view call `DraftService` directly). diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index c52f9f3ac9..291af0f6dd 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -621,10 +621,11 @@ if (pages.length === 0) return for (const p of pages) pendingPages.add(p) // A write reaches a hosted editor through the draft cell it holds, but an - // editor still loading holds none yet and `seed` no-opped past it — so ask - // whether the cell was live, and refresh the ones the write missed. + // editor still loading holds none yet and the seed no-opped past it. The + // miss is recorded when it happens — asking now would be too late, since + // the editor can have acquired the cell during the write's round-trip. const kind = path ? entityKindForPage(pages[0]) : undefined - const seedReachedEditor = !kind || !path || UserDraft.hasLiveEntry(kind, path, { workspace }) + const seedReachedEditor = !kind || !path || !UserDraft.takeSeedMiss(kind, path, { workspace }) const effect = effectForWrite(entity, seedReachedEditor) if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace }) clearTimeout(reloadHandle) From 98e30f087801279d9ce39226db60c8fc59b0dec7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 20:39:01 +0200 Subject: [PATCH 10/69] fix(sessions): leave a hosted editor whose draft-only item was discarded Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/copilot/chat/global/core.ts | 34 ++++++++++++++++--- .../components/sessions/previewReload.test.ts | 21 +++++++++++- .../lib/components/sessions/previewReload.ts | 11 ++++++ frontend/src/lib/userDraft.svelte.ts | 22 ++++++++++++ .../(root)/(logged)/sessions/+page.svelte | 7 +++- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e919769e7a..d0594a6a5b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6135,6 +6135,25 @@ function createOpenVariableAction(path: string): ToolDisplayAction { } } +/** Whether a deployed item exists under a draft, for the kinds whose editor a + * session hosts. Anything else answers true — the caller only uses this to decide + * whether discarding leaves nothing behind, and no other kind is hosted. */ +async function hasDeployedItem( + workspace: string, + type: WorkspaceItemType, + path: string +): Promise { + try { + if (type === 'resource') return await ResourceService.existsResource({ workspace, path }) + if (type === 'variable') return await VariableService.existsVariable({ workspace, path }) + if (type === 'schedule') return await ScheduleService.existsSchedule({ workspace, path }) + } catch { + // The probe is an optimisation over "assume it survived"; a failed one must + // not fail the discard, and treating it as deployed keeps today's behavior. + } + return true +} + async function discardLocalDraft( args: { type: WorkspaceItemType; path: string; trigger_kind?: TriggerKind }, ctx: WriteDraftCtx @@ -6151,16 +6170,21 @@ async function discardLocalDraft( throw new Error(`No draft found for ${type} "${path}".`) } + // Before the delete, while both sides are still knowable: a draft with nothing + // deployed under it IS the item, so discarding it removes the item rather than + // reverting it — and anything showing that item has to stop. + const discardedKind = itemKindFor(type, triggerKind) + const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) + if (discardedKind && !(await hasDeployedItem(workspace, type, path))) { + UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) + } + await deleteGlobalDraft(workspace, type, path, triggerKind) // The chat's touch on the item is undone — drop it from the mask so a // pre-existing deployed item doesn't keep reading as this chat's edit. - const discardedKind = itemKindFor(type, triggerKind) if (discardedKind) { - toolCallbacks.onItemDiscarded?.( - discardedKind, - getGlobalDraftStoragePath(workspace, type, path, triggerKind) - ) + toolCallbacks.onItemDiscarded?.(discardedKind, storagePath) } toolCallbacks.setToolStatus(toolId, { diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index db4f0de977..a399f1bda6 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -4,7 +4,8 @@ import { tabsToReload, strongerEntityEffect, entityEffectForTab, - effectForWrite + effectForWrite, + effectForDiscard } from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' @@ -101,6 +102,24 @@ describe('effectForWrite', () => { }) }) +describe('effectForDiscard', () => { + // Discarding reverts an item to what is deployed — unless nothing is, in which + // case the draft WAS the item and the editor is left showing something that no + // longer exists, remounting into a load that cannot resolve. + it('closes a hosted editor whose item the discard removed outright', () => { + expect(effectForDiscard('refresh', false)).toBe('close') + }) + + it('keeps a deployed item on screen, reverted rather than closed', () => { + expect(effectForDiscard('refresh', true)).toBe('refresh') + }) + + it('leaves the other effects alone', () => { + expect(effectForDiscard('none', false)).toBe('none') + expect(effectForDiscard('close', true)).toBe('close') + }) +}) + describe('entityEffectForTab', () => { const del = (path: string, workspace = 'ws1') => ({ pages: ['/resources'], diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 69cac9c5b3..dfbf70eeff 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -109,6 +109,17 @@ export function effectForWrite( return effect === 'none' && !seedReachedEditor ? 'refresh' : effect } +/** What a discard asks of a hosted editor. Discarding a draft that had a deployed + * item under it reverts the editor to that item, which a refresh shows — but a + * draft-only item has nothing to revert to, so the discard removed it and the + * editor has to leave rather than remount into a load that cannot resolve. */ +export function effectForDiscard( + effect: EntityToolEffect, + itemSurvives: boolean +): EntityToolEffect { + return effect === 'refresh' && !itemSurvives ? 'close' : effect +} + /** The stronger of two effects, for a tab several of a round's mutations reach: * a delete outranks a refresh, which outranks nothing. */ export function strongerEntityEffect(a: EntityToolEffect, b: EntityToolEffect): EntityToolEffect { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index b2699818da..2ea187c014 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -127,6 +127,8 @@ const entries = new Map() // Cells whose last `seed` found no live entry (see `takeSeedMiss`). Bounded by // the seeds that missed and not yet been read back, and each entry is one key. const seedMisses = new Set() +// Cells whose last discard removed the item itself (see `takeDraftOnlyDiscard`). +const draftOnlyDiscards = new Set() const liveEditorDrafts = new Map() /** * Map keys whose entry should start `syncSuspended` on acquire. Lets @@ -415,6 +417,26 @@ export const UserDraft = { entry.state.val = snapshotDraftValue(value) }, + /** + * Record that the draft just discarded for this cell was the item's only stored + * form — nothing deployed underneath, so the item is now gone rather than + * reverted. Read once by a consumer showing that item, which has to stop + * showing it. Set at the discard, where the deployed side is known. + */ + recordDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + draftOnlyDiscards.add(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + + /** Whether the last discard for this cell removed the item outright (see + * {@link recordDraftOnlyDiscard}), clearing the record. */ + takeDraftOnlyDiscard( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions + ): boolean { + return draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + /** * Whether the last {@link seed} for this cell found no live entry, clearing the * record. An editor acquires its cell only once its first load resolves, so a diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 291af0f6dd..3f730a40e8 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -77,6 +77,7 @@ tabsToReload, entityEffectForTab, effectForWrite, + effectForDiscard, type EntityMutation } from '$lib/components/sessions/previewReload' import { @@ -626,7 +627,11 @@ // the editor can have acquired the cell during the write's round-trip. const kind = path ? entityKindForPage(pages[0]) : undefined const seedReachedEditor = !kind || !path || !UserDraft.takeSeedMiss(kind, path, { workspace }) - const effect = effectForWrite(entity, seedReachedEditor) + // A discard of a draft-only item removed the item itself, so its editor has + // nothing left to re-read and must leave instead. + const itemSurvives = + !kind || !path || !UserDraft.takeDraftOnlyDiscard(kind, path, { workspace }) + const effect = effectForDiscard(effectForWrite(entity, seedReachedEditor), itemSurvives) if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace }) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) From c9c3b235857a54650155ccd5f506ee3dfc31c4e6 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 21:11:16 +0200 Subject: [PATCH 11/69] fix(sessions): publish the removal marker only once the discard lands Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../copilot/chat/global/core.test.ts | 24 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 18 +++++++++----- .../lib/components/sessions/previewReload.ts | 14 +++++------ frontend/src/lib/userDraft.svelte.ts | 19 +++++++++++++-- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 19050bb7da..5c069f189f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2136,6 +2136,30 @@ describe('global AI tools', () => { ).toBeUndefined() }) + // The removal marker tells a session's hosted editor that its item is gone, and + // it is read by the tool-completion listener — which a throw never reaches. Left + // behind by a failed discard it would be consumed by some later action on the + // same item and send that editor away while the item is still there. + it('records the draft-only removal marker only once the discard has landed', async () => { + const path = 'f/resources/discard-marker' + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'localhost' } + }) + + // The delete is a `value: null` draft write, so failing that write fails it. + failingWrites.add(`resource:${path}`) + await expect(callGlobalTool('discard_local_draft', { type: 'resource', path })).rejects.toThrow() + expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false) + + // The same discard, allowed through, does publish it (so the check above is + // about the failure, not about the marker never being written at all). + failingWrites.delete(`resource:${path}`) + await callGlobalTool('discard_local_draft', { type: 'resource', path }) + expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(true) + }) + // "Create a resource, then never mind": delete_workspace_item must reject a path // that was never deployed, before the confirmation card — otherwise the user // confirms a workspace mutation that 404s past the draft cleanup, leaving the diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index d0594a6a5b..18dbf9c077 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6170,17 +6170,23 @@ async function discardLocalDraft( throw new Error(`No draft found for ${type} "${path}".`) } - // Before the delete, while both sides are still knowable: a draft with nothing - // deployed under it IS the item, so discarding it removes the item rather than - // reverting it — and anything showing that item has to stop. + // Probed before the delete, while both sides are still knowable: a draft with + // nothing deployed under it IS the item, so discarding it removes the item + // rather than reverting it — and anything showing that item has to stop. const discardedKind = itemKindFor(type, triggerKind) const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) - if (discardedKind && !(await hasDeployedItem(workspace, type, path))) { - UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) - } + const removesItem = !!discardedKind && !(await hasDeployedItem(workspace, type, path)) await deleteGlobalDraft(workspace, type, path, triggerKind) + // Published only now: the delete above can throw, and the marker is read by the + // tool-completion listener, which a throw never reaches — leaving it to be + // consumed by some later action on this item, which would send its editor away + // while the item is still there. + if (removesItem && discardedKind) { + UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) + } + // The chat's touch on the item is undone — drop it from the mask so a // pre-existing deployed item doesn't keep reading as this chat's edit. if (discardedKind) { diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index dfbf70eeff..8d92eab21b 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -2,6 +2,13 @@ import type { SessionPreviewTab } from './sessionState.svelte' import { whereIs } from './sessionPreviewTabs.svelte' import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' +/** What a tool asks of a hosted entity editor (see previewRouter's + * `IN_REALM_ENTITY_PAGES`) showing one of the affected pages. A plain write + * reaches it on its own — it holds the draft cell the write seeds — but the + * tools that clear or replace that cell go behind it, and the item can be gone + * altogether. `none` is what makes the live-editing case live. */ +export type EntityToolEffect = 'none' | 'refresh' | 'close' + // 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 // this tool change a list page a preview tab might show". A new mutating tool @@ -15,13 +22,6 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' // 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. -/** What a tool asks of a hosted entity editor (see previewRouter's - * `IN_REALM_ENTITY_PAGES`) showing one of the affected pages. A plain write - * reaches it on its own — it holds the draft cell the write seeds — but the - * tools that clear or replace that cell go behind it, and the item can be gone - * altogether. `none` is what makes the live-editing case live. */ -export type EntityToolEffect = 'none' | 'refresh' | 'close' - export type ToolReloadEffect = { pages: string[] entity: EntityToolEffect diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 2ea187c014..6a5385b2c7 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -129,6 +129,21 @@ const entries = new Map() const seedMisses = new Set() // Cells whose last discard removed the item itself (see `takeDraftOnlyDiscard`). const draftOnlyDiscards = new Set() + +// Both sets above are read by the tool-completion listener right after the write +// that wrote them — but only while something is listening, and nothing is when the +// user is not in a session. Capped so an unread marker cannot accumulate: the +// oldest is dropped, since a marker is only ever meaningful to the action that +// immediately follows it. +const MAX_WRITE_MARKERS = 64 +function noteMarker(set: Set, key: string): void { + set.add(key) + while (set.size > MAX_WRITE_MARKERS) { + const oldest = set.values().next().value + if (oldest === undefined || oldest === key) break + set.delete(oldest) + } +} const liveEditorDrafts = new Map() /** * Map keys whose entry should start `syncSuspended` on acquire. Lets @@ -409,7 +424,7 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (!entry) { - seedMisses.add(mk) + noteMarker(seedMisses, mk) return } seedMisses.delete(mk) @@ -424,7 +439,7 @@ export const UserDraft = { * showing it. Set at the discard, where the deployed side is known. */ recordDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { - draftOnlyDiscards.add(mapKey(resolveWorkspace(opts), itemKind, path)) + noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, /** Whether the last discard for this cell removed the item outright (see From 947fd6b1cb539ad7eba52be8a504a1f5ed4f9588 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 5 Sep 2026 21:44:07 +0200 Subject: [PATCH 12/69] fix(sessions): spend each write marker only on the tool that wrote it Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../copilot/chat/global/core.test.ts | 21 +++++++++++++++++++ .../copilot/chat/global/userDraftAdapter.ts | 3 +++ frontend/src/lib/userDraft.svelte.ts | 7 +++++++ .../(root)/(logged)/sessions/+page.svelte | 12 +++++++++-- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 5c069f189f..a6c1d763f8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2160,6 +2160,27 @@ describe('global AI tools', () => { expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(true) }) + // Nothing consumes a marker outside a session, so one can outlive the item it + // was about. Recreating the item must void it, or the next action on the + // recreated item reads it and sends a perfectly valid editor back to its list. + it('voids a draft-only removal marker when the item is written again', async () => { + const path = 'f/resources/discard-then-recreate' + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'localhost' } + }) + await callGlobalTool('discard_local_draft', { type: 'resource', path }) + + // The marker is standing (no listener consumed it), and now the item is back. + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'elsewhere' } + }) + expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false) + }) + // "Create a resource, then never mind": delete_workspace_item must reject a path // that was never deployed, before the confirmation card — otherwise the user // confirms a workspace mutation that 404s past the draft cleanup, leaving the diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index f2c3f02431..3b1eeab8e8 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -421,6 +421,9 @@ export async function persistGlobalDraft( const itemKind = itemKindFor(type, opts.triggerKind) if (!itemKind) throw new Error(`Unsupported draft type "${type}".`) const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + // Writing the item back means it exists again, so any removal marker left from + // an earlier draft-only discard is void. + UserDraft.clearDraftOnlyDiscard(itemKind, storagePath, { workspace }) UserDraft.seed(itemKind, storagePath, value, { workspace }) await UserDraftDbSyncer.save({ workspace, diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 6a5385b2c7..aff6e3c932 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -442,6 +442,13 @@ export const UserDraft = { noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, + /** Drop any removal marker for this cell — the item exists again, so a marker + * still standing (nothing consumed it, because nothing was listening) would be + * read by whatever touches it next and send its editor away. */ + clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + /** Whether the last discard for this cell removed the item outright (see * {@link recordDraftOnlyDiscard}), clearing the record. */ takeDraftOnlyDiscard( diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 3f730a40e8..fd675ad7be 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -625,12 +625,20 @@ // editor still loading holds none yet and the seed no-opped past it. The // miss is recorded when it happens — asking now would be too late, since // the editor can have acquired the cell during the write's round-trip. + // Each marker is read only by the kind of tool that writes it. A marker + // left unread — nothing consumes them outside a session — would otherwise + // be spent by whatever touched the item next, and a removal marker read by + // a deploy sends a live editor away. const kind = path ? entityKindForPage(pages[0]) : undefined - const seedReachedEditor = !kind || !path || !UserDraft.takeSeedMiss(kind, path, { workspace }) + const readable = !!kind && !!path + const seedReachedEditor = + !readable || entity !== 'none' || !UserDraft.takeSeedMiss(kind!, path!, { workspace }) // A discard of a draft-only item removed the item itself, so its editor has // nothing left to re-read and must leave instead. const itemSurvives = - !kind || !path || !UserDraft.takeDraftOnlyDiscard(kind, path, { workspace }) + !readable || + name !== 'discard_local_draft' || + !UserDraft.takeDraftOnlyDiscard(kind!, path!, { workspace }) const effect = effectForDiscard(effectForWrite(entity, seedReachedEditor), itemSurvives) if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace }) clearTimeout(reloadHandle) From 420beeadd2ef66d79aa3eb39447632cd6fae51dd Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 00:35:37 +0200 Subject: [PATCH 13/69] fix(sessions): leave a hosted editor whose item a direct discard removed, and follow a renamed save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline draft banner's Discard calls UserDraft.discard straight off, not the discard_local_draft tool, so nothing told the tab about it. For a draft-only item the draft IS the item: discarding deletes its only representation and the tab kept editing a synthesized stand-in for something that no longer exists. Each editor now reports whether the item survived, and the session views hand that back to the tab, which returns to the list it was opened from. A draft-only schedule also opens with its path editable — saving CREATEs — so the save can land somewhere other than where the tab points. The schedule host discarded onUpdate's saved path and only remounted, re-opening the old path; it now retargets the tab like the resource and variable hosts do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/ResourceEditor.svelte | 9 +++++++-- .../lib/components/ResourceEditorDrawer.svelte | 13 ++++++++++--- frontend/src/lib/components/VariableEditor.svelte | 10 +++++++++- .../lib/components/sessions/PreviewTabHost.svelte | 7 ++++++- .../components/sessions/ResourceEditorView.svelte | 1 + .../components/sessions/ScheduleEditorView.svelte | 15 +++++++++++++-- .../components/sessions/VariableEditorView.svelte | 1 + .../triggers/schedules/ScheduleEditorInner.svelte | 15 +++++++++++++-- 8 files changed, 60 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 89acac79de..ddea758bf4 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -290,11 +290,16 @@ export function localDraftCurrent(): ResourceState | undefined { return current } - export function discardLocalDraft(): void { - if (!selected) return + /** Returns whether the resource still exists afterwards. Discarding a draft + * that was never deployed removes the resource itself — `initialStates` holds a + * synthesized stand-in, not a baseline to fall back to — so a caller showing it + * has to stop rather than keep displaying that stand-in. */ + export function discardLocalDraft(): boolean { + if (!selected) return true UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { workspace: selected }) + return !!existedInitially[selected] } $effect(() => { diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 795a413cbd..5d444bede6 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -25,7 +25,8 @@ onRestored = undefined, onSaved = undefined, useDrawer = true, - onBack = undefined + onBack = undefined, + onRemoved = undefined }: { workspace?: string disableChatOffset?: boolean @@ -44,6 +45,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 + /** The resource is gone — a draft-only one whose draft was discarded. A host + * addressing it by path (a session tab) has to stop showing it. */ + onRemoved?: () => void } = $props() let drawer: Drawer | undefined = $state() @@ -61,7 +65,8 @@ save: () => Promise localDraftDeployed: () => unknown localDraftCurrent: () => unknown - discardLocalDraft: () => void + /** False when the discard removed the resource (it was draft-only). */ + discardLocalDraft: () => boolean } | undefined = $state(undefined) let hasLocalDraft = $state(false) @@ -171,7 +176,9 @@ reserveSpace={mode == 'edit'} getDeployed={() => resourceEditor?.localDraftDeployed()} getCurrent={() => resourceEditor?.localDraftCurrent()} - onDiscard={() => resourceEditor?.discardLocalDraft()} + onDiscard={() => { + if (resourceEditor?.discardLocalDraft() === false) onRemoved?.() + }} disabled={!canWriteSelected} /> {/snippet} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 8b0d78800b..6d67e0c0fd 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -43,7 +43,8 @@ workspace = undefined, useDrawer = true, onBack = undefined, - onSaved = undefined + onSaved = undefined, + onRemoved = undefined }: { workspace?: string /** @@ -59,6 +60,9 @@ /** 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 + /** The variable is gone — a draft-only one whose draft was discarded. A host + * addressing it by path (a session tab) has to stop showing it. */ + onRemoved?: () => void } = $props() let curWs = $derived(workspace ?? $workspaceStore) @@ -343,6 +347,10 @@ UserDraft.discard('variable', editPath ?? '', initialStates[selected], { workspace: selected }) + // A draft-only variable has no deployed row under the draft, so discarding + // it removed the variable: `initialStates` holds a synthesized stand-in, + // not a baseline to fall back to. + if (!existedInitially[selected]) onRemoved?.() }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index a44f64de1b..3fae60312e 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -373,7 +373,12 @@ {#await import('./ScheduleEditorView.svelte')} {@render editorLoading()} {:then Module} - + {/await} {:else if slot.entityKind === 'resource'} {#await import('./ResourceEditorView.svelte')} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 12c5720766..1706d226ce 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -38,6 +38,7 @@ useDrawer={false} workspace={workspaceId} {onBack} + onRemoved={onBack} onSaved={(saved) => { 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 f221d63ec0..a1c99b02da 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -8,7 +8,8 @@ let { path, workspaceId, - onBack + onBack, + onRenamed }: { /** The schedule this tab edits (the row its location deep-links). */ path: string @@ -17,6 +18,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() // Captured at init, so it must read the current prop rather than close over it. @@ -59,7 +64,13 @@ bind:this={editor} useDrawer={false} showDraftBanner - onUpdate={() => generation++} + onRemoved={onBack} + onUpdate={(saved: string | undefined) => { + generation++ + // A draft-only schedule opens with its path editable (saving CREATEs), + // so the save can land somewhere other than where the tab is pointed. + if (saved && saved !== path) onRenamed?.(saved) + }} > {#snippet customLabel()}
diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index b6397cecc6..4c5dce93da 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -38,6 +38,7 @@ useDrawer={false} workspace={workspaceId} {onBack} + onRemoved={onBack} onSaved={(saved) => { if (saved && saved !== path) onRenamed?.(saved) }} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 1aac6d1076..9b9b2ee63f 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -68,7 +68,10 @@ // 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 + showDraftBanner = false, + // The schedule is gone — a draft-only one whose draft the banner discarded. + // A host addressing it by path (a session tab) has to stop showing it. + onRemoved = undefined } = $props() let optionTabSelected: @@ -79,6 +82,9 @@ | 'dynamic_skip' = $state('error_handler') let initialPath = $state('') let edit = $state(true) + // Opened on a schedule that exists only as a draft: its draft IS the schedule, + // so discarding it removes the item rather than reverting it. + let draftOnly = $state(false) let schedule: string = $state('0 0 12 * *') let cronVersion: string = $state('v2') let isLatestCron = $state(true) @@ -183,6 +189,7 @@ const { overlay: draftOverlay, noDeployed } = await loadSchedule(defaultCfg) // Draft-only schedules have no deployed row, so saving must CREATE (update 404s). edit = !noDeployed + draftOnly = noDeployed if (!defaultCfg) { // Form holds DEPLOYED here; capture it as `initialConfig` so the // dirty check / banner fires whenever a saved draft exists. @@ -339,6 +346,7 @@ drawer?.openDrawer() runnable = undefined edit = false + draftOnly = false // No deployed baseline for a brand-new schedule. The editor instance // is reused across open() calls, so clear any baseline left by a prior // openEdit — otherwise the "unsaved changes" banner / dirty check would @@ -1455,7 +1463,10 @@ getDeployed={() => draftSync.deployed} reserveSpace={draftSync.hasBaseline} getCurrent={() => draftSync.current} - onDiscard={() => draftSync.resetToDeployed(initialPath)} + onDiscard={async () => { + await draftSync.resetToDeployed(initialPath) + if (draftOnly) onRemoved?.() + }} disabled={!can_write} /> {/if} From 3f8485060dd3e0f87f42e794c3e20a3b2a918bd9 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 01:09:15 +0200 Subject: [PATCH 14/69] fix(sessions): send the removed item's own tab back to the list, not the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A discard resolves asynchronously — the schedule's awaits a fetch of the runnable — so by the time it lands the user may be looking at another tab. Routing removal through `onBack` moved whichever tab was active, taking that one to the list while the tab whose item was gone stayed on it. Removal now retargets by tab id, as the deletion and rename paths already do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../lib/components/sessions/PreviewTabHost.svelte | 13 +++++++++++++ .../components/sessions/ResourceEditorView.svelte | 7 ++++++- .../components/sessions/ScheduleEditorView.svelte | 7 ++++++- .../components/sessions/VariableEditorView.svelte | 7 ++++++- frontend/src/lib/userDraft.svelte.ts | 8 +++----- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 3fae60312e..5317ba5fb8 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -179,6 +179,16 @@ : undefined ) + // The item is gone (a draft-only one whose draft was discarded), so the tab has + // the same destination as `backToList` — but bound to this tab by id, not to + // whichever is active: a discard completes asynchronously, and by then the user + // may be looking at another tab, which must not be the one sent to the list. + const returnToList = $derived( + slot.kind === 'entity' && runtime + ? () => runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) + : 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. @@ -377,6 +387,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} @@ -388,6 +399,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} @@ -399,6 +411,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 1706d226ce..e6a7f3204b 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -6,6 +6,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The resource this tab edits (the row its location deep-links). */ @@ -15,6 +16,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: a discard can complete after + * the user has moved on, and only this tab is the one to send back. */ + onRemoved?: () => 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. */ @@ -38,7 +43,7 @@ useDrawer={false} workspace={workspaceId} {onBack} - onRemoved={onBack} + {onRemoved} onSaved={(saved) => { 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 a1c99b02da..b2fadb1f46 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -9,6 +9,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The schedule this tab edits (the row its location deep-links). */ @@ -18,6 +19,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: the discard awaits a reload of + * the runnable, by which time the user may be looking at another tab. */ + onRemoved?: () => 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. */ @@ -64,7 +69,7 @@ bind:this={editor} useDrawer={false} showDraftBanner - onRemoved={onBack} + {onRemoved} onUpdate={(saved: string | undefined) => { generation++ // A draft-only schedule opens with its path editable (saving CREATEs), diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index 4c5dce93da..13edc6e467 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -6,6 +6,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The variable this tab edits (the row its location deep-links). */ @@ -15,6 +16,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: a discard can complete after + * the user has moved on, and only this tab is the one to send back. */ + onRemoved?: () => 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. */ @@ -38,7 +43,7 @@ useDrawer={false} workspace={workspaceId} {onBack} - onRemoved={onBack} + {onRemoved} onSaved={(saved) => { if (saved && saved !== path) onRenamed?.(saved) }} diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index aff6e3c932..1fc0aafaac 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -130,11 +130,9 @@ const seedMisses = new Set() // Cells whose last discard removed the item itself (see `takeDraftOnlyDiscard`). const draftOnlyDiscards = new Set() -// Both sets above are read by the tool-completion listener right after the write -// that wrote them — but only while something is listening, and nothing is when the -// user is not in a session. Capped so an unread marker cannot accumulate: the -// oldest is dropped, since a marker is only ever meaningful to the action that -// immediately follows it. +// Nothing reads either set outside a session, so unread markers would accumulate. +// Capped by dropping the oldest: a marker means something only to the action that +// immediately follows the write that left it. const MAX_WRITE_MARKERS = 64 function noteMarker(set: Set, key: string): void { set.add(key) From 9b37f57503cd66701634962631561f40c520a117 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 01:58:39 +0200 Subject: [PATCH 15/69] fix(sessions): act on an entity callback only while the tab still shows its item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab id outlives what the tab points at. An editor reports a removal or a rename after the write it awaited, and by then the tab may have been re-pointed onto another item — so the report was applied to whatever the tab held, and the URL built out of that unrelated location: discarding schedule A and opening schedule B sent B back to the list. Every such report now carries the path it started from, read before the await, and the host acts only while the tab is still showing that entity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 23 +++++++++++------- .../src/lib/components/VariableEditor.svelte | 24 ++++++++++++------- .../components/sessions/PreviewTabHost.svelte | 24 +++++++++++++++---- .../sessions/ResourceEditorView.svelte | 21 ++++++++-------- .../sessions/ScheduleEditorView.svelte | 22 +++++++++-------- .../sessions/VariableEditorView.svelte | 21 ++++++++-------- .../schedules/ScheduleEditorInner.svelte | 22 ++++++++++------- 7 files changed, 98 insertions(+), 59 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 5d444bede6..444cf55809 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -32,9 +32,10 @@ disableChatOffset?: boolean onRestored?: () => 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 + * one it was opened on when the user renamed it — and the one it was opened + * on. For a caller showing state derived from the resource; `onRestored` only + * covers restoring a version. */ + onSaved?: (savedPath?: string, fromPath?: 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 @@ -45,9 +46,10 @@ /** 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 - /** The resource is gone — a draft-only one whose draft was discarded. A host - * addressing it by path (a session tab) has to stop showing it. */ - onRemoved?: () => void + /** The resource is gone — a draft-only one whose draft was discarded — with + * the path it was showing. A host addressing it by path (a session tab) has + * to stop showing it. */ + onRemoved?: (fromPath: string) => void } = $props() let drawer: Drawer | undefined = $state() @@ -177,7 +179,8 @@ getDeployed={() => resourceEditor?.localDraftDeployed()} getCurrent={() => resourceEditor?.localDraftCurrent()} onDiscard={() => { - if (resourceEditor?.discardLocalDraft() === false) onRemoved?.() + const from = path + if (resourceEditor?.discardLocalDraft() === false && from) onRemoved?.(from) }} disabled={!canWriteSelected} /> @@ -210,6 +213,10 @@ unifiedSize="md" startIcon={{ icon: Save }} on:click={async () => { + // The path this save started from. Read before the await: an inline host can + // re-point the editor at another resource while the write is in flight, and + // the caller needs to know which one the result belongs to. + const from = path // 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. @@ -225,7 +232,7 @@ const savedPath = livePath ?? path if (savedPath) path = savedPath editorGeneration++ - onSaved?.(savedPath) + onSaved?.(savedPath, from) }} disabled={!canSave} > diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 6d67e0c0fd..3d19050809 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -58,11 +58,12 @@ * 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 - /** The variable is gone — a draft-only one whose draft was discarded. A host - * addressing it by path (a session tab) has to stop showing it. */ - onRemoved?: () => void + * opened on when the user renamed it — and the one it was opened on. */ + onSaved?: (savedPath?: string, fromPath?: string) => void + /** The variable is gone — a draft-only one whose draft was discarded — with the + * path it was showing. A host addressing it by path (a session tab) has to stop + * showing it. */ + onRemoved?: (fromPath: string) => void } = $props() let curWs = $derived(workspace ?? $workspaceStore) @@ -277,6 +278,10 @@ async function save(): Promise { const dirty = dirtyWorkspaces + // The path this save started from. Read before the awaits: an inline host can + // re-point the editor at another variable while the write is in flight, and the + // caller needs to know which one the result belongs to. + const from = editPath try { for (const ws of dirty) { const s = states[ws].draft! @@ -326,10 +331,10 @@ 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 + const savedPath = current?.path ?? from if (savedPath && savedPath !== editPath) editPath = savedPath drawer?.closeDrawer() - onSaved?.(savedPath) + onSaved?.(savedPath, from) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) } @@ -344,13 +349,14 @@ getCurrent={() => current} onDiscard={() => { if (!selected) return - UserDraft.discard('variable', editPath ?? '', initialStates[selected], { + const from = editPath ?? '' + UserDraft.discard('variable', from, initialStates[selected], { workspace: selected }) // A draft-only variable has no deployed row under the draft, so discarding // it removed the variable: `initialStates` holds a synthesized stand-in, // not a baseline to fall back to. - if (!existedInitially[selected]) onRemoved?.() + if (!existedInitially[selected] && from) onRemoved?.(from) }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 5317ba5fb8..5175acd86b 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -179,13 +179,27 @@ : undefined ) + // An editor reports a removal or a rename after the write it awaited, and the tab + // it was mounted for may have moved on in the meantime — retargeted onto another + // item, or onto a different page entirely. The report belongs to the item it + // started on, so both callbacks below act only while the tab is still showing it: + // otherwise they would re-point whatever the tab holds now, and build the URL out + // of that unrelated location. + function stillShowing(path: string): boolean { + const now = resolvePreviewTab(tab.url) + return now.kind === 'entity' && now.path === path + } + // The item is gone (a draft-only one whose draft was discarded), so the tab has // the same destination as `backToList` — but bound to this tab by id, not to - // whichever is active: a discard completes asynchronously, and by then the user - // may be looking at another tab, which must not be the one sent to the list. + // whichever is active: by the time a discard lands the user may be looking at + // another tab, which must not be the one sent to the list. const returnToList = $derived( slot.kind === 'entity' && runtime - ? () => runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) + ? (fromPath: string) => { + if (!stillShowing(fromPath)) return + runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) + } : undefined ) @@ -194,8 +208,10 @@ // 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) => + ? (newPath: string, fromPath: string) => { + if (!stillShowing(fromPath)) return runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath)) + } : undefined ) diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index e6a7f3204b..d645d16a02 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -16,14 +16,15 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void - /** The item is gone — a draft-only one whose draft was discarded. Distinct from - * `onBack`, which moves whichever tab is active: a discard can complete after - * the user has moved on, and only this tab is the one to send back. */ - onRemoved?: () => 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 + /** The item at `fromPath` is gone — a draft-only one whose draft was discarded. + * Distinct from `onBack`, which moves whichever tab is active: only this tab is + * the one to send back, and only while it still shows that item. */ + onRemoved?: (fromPath: string) => void + /** The item at `fromPath` 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, fromPath: string) => void } = $props() let editor = $state() @@ -44,7 +45,7 @@ workspace={workspaceId} {onBack} {onRemoved} - onSaved={(saved) => { - if (saved && saved !== path) onRenamed?.(saved) + onSaved={(saved, from) => { + if (saved && from && saved !== from) onRenamed?.(saved, from) }} /> diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index b2fadb1f46..599c8ac8cd 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -19,14 +19,16 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void - /** The item is gone — a draft-only one whose draft was discarded. Distinct from - * `onBack`, which moves whichever tab is active: the discard awaits a reload of - * the runnable, by which time the user may be looking at another tab. */ - onRemoved?: () => 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 + /** The item at `fromPath` is gone — a draft-only one whose draft was discarded. + * Distinct from `onBack`, which moves whichever tab is active: the discard + * awaits a reload of the runnable, by which time the user may be looking at + * another tab, or have pointed this one somewhere else. */ + onRemoved?: (fromPath: string) => void + /** The item at `fromPath` 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, fromPath: string) => void } = $props() // Captured at init, so it must read the current prop rather than close over it. @@ -70,11 +72,11 @@ useDrawer={false} showDraftBanner {onRemoved} - onUpdate={(saved: string | undefined) => { + onUpdate={(saved: string | undefined, from: string) => { generation++ // A draft-only schedule opens with its path editable (saving CREATEs), // so the save can land somewhere other than where the tab is pointed. - if (saved && saved !== path) onRenamed?.(saved) + if (saved && saved !== from) onRenamed?.(saved, from) }} > {#snippet customLabel()} diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index 13edc6e467..0cc56de817 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -16,14 +16,15 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void - /** The item is gone — a draft-only one whose draft was discarded. Distinct from - * `onBack`, which moves whichever tab is active: a discard can complete after - * the user has moved on, and only this tab is the one to send back. */ - onRemoved?: () => 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 + /** The item at `fromPath` is gone — a draft-only one whose draft was discarded. + * Distinct from `onBack`, which moves whichever tab is active: only this tab is + * the one to send back, and only while it still shows that item. */ + onRemoved?: (fromPath: string) => void + /** The item at `fromPath` 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, fromPath: string) => void } = $props() let editor = $state() @@ -44,7 +45,7 @@ workspace={workspaceId} {onBack} {onRemoved} - onSaved={(saved) => { - if (saved && saved !== path) onRenamed?.(saved) + onSaved={(saved, from) => { + if (saved && from && saved !== from) onRenamed?.(saved, from) }} /> diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 9b9b2ee63f..b02b68e65c 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -69,8 +69,9 @@ // 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, - // The schedule is gone — a draft-only one whose draft the banner discarded. - // A host addressing it by path (a session tab) has to stop showing it. + // The schedule is gone — a draft-only one whose draft the banner discarded — + // with the path it was showing. A host addressing it by path (a session tab) + // has to stop showing it. onRemoved = undefined } = $props() @@ -619,7 +620,7 @@ const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { draftSync.discard(previousPath, scheduleCfg) - onUpdate?.(scheduleCfg.path) + onUpdate?.(scheduleCfg.path, previousPath) drawer?.closeDrawer() } deploymentLoading = false @@ -717,12 +718,13 @@ async function handleToggleEnabled(nEnabled: boolean) { const previousEnabled = enabled + const path = initialPath enabled = nEnabled if (!trigger?.draftConfig) { const ok = await withForkConflictRetry( (force) => ScheduleService.setScheduleEnabled({ - path: initialPath, + path, workspace: wsId ?? '', requestBody: { enabled: nEnabled, force } }), @@ -732,8 +734,8 @@ enabled = previousEnabled return } - sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${initialPath}`) - onUpdate?.(initialPath) + sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) + onUpdate?.(path, path) } } @@ -1464,8 +1466,12 @@ reserveSpace={draftSync.hasBaseline} getCurrent={() => draftSync.current} onDiscard={async () => { - await draftSync.resetToDeployed(initialPath) - if (draftOnly) onRemoved?.() + // The path the discard started from: it awaits a reload of the runnable, + // and an inline host can re-point the editor in the meantime. + const from = initialPath + const wasDraftOnly = draftOnly + await draftSync.resetToDeployed(from) + if (wasDraftOnly && from) onRemoved?.(from) }} disabled={!can_write} /> From 5373cc29842e68162076e5f9ae9e5a167b53ed74 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 02:42:26 +0200 Subject: [PATCH 16/69] fix(sessions): stop a stale write from landing on the item the editor moved to Guarding the host was not enough: an editor's own continuation runs first, and every piece of state it touches after the await belongs to whatever the editor shows by then. The variable save now pins its payloads, its saved path and its draft key before the first write and only adopts a baseline while it is still the variable it saved; the resource save and the schedule remount stop at the same check. The host guard also matched on path alone, so a late report about a schedule reached a resource or variable that happened to share its path. It now matches the entity kind too, bound at the branch that mounts each editor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 4 ++ .../src/lib/components/VariableEditor.svelte | 46 ++++++++++++------- .../components/sessions/PreviewTabHost.svelte | 35 +++++++------- .../sessions/ScheduleEditorView.svelte | 4 ++ .../schedules/ScheduleEditorInner.svelte | 5 +- 5 files changed, 57 insertions(+), 37 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 444cf55809..ec86e9e4c1 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -226,6 +226,10 @@ // must not run for a write that failed — a rejected rename (a name // collision, say) would point the tab at a path this save never created. if (!(await saving)) return + // An inline host re-pointed this editor while the write was in flight, so + // `path`, `livePath` and the mounted editor are another resource's now. + // Moving any of them onto this write's result would move that one instead. + if (path !== from) return // 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. diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 3d19050809..d555e725b3 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -277,16 +277,21 @@ } async function save(): Promise { - const dirty = dirtyWorkspaces - // The path this save started from. Read before the awaits: an inline host can - // re-point the editor at another variable while the write is in flight, and the - // caller needs to know which one the result belongs to. + // Everything the writes need, read before the first await. An inline host can + // re-point this editor at another variable mid-flight, and every one of these + // would then be that variable's: the writes would send its state under this + // one's path, and the baseline below would overwrite its own. const from = editPath + const savedPath = current?.path ?? from + const payloads = dirtyWorkspaces.map((ws) => ({ + ws, + s: $state.snapshot(states[ws].draft!) as VariableState, + ini: $state.snapshot(initialStates[ws]) as VariableState, + existed: !!existedInitially[ws] + })) try { - for (const ws of dirty) { - const s = states[ws].draft! - const ini = initialStates[ws] - if (existedInitially[ws]) { + for (const { ws, s, ini, existed } of payloads) { + if (existed) { await VariableService.updateVariable({ workspace: ws, path: ini.path, @@ -320,20 +325,27 @@ // handle to it via `discard` (not `remove` — blanking the cell to // `undefined` reads as dirty). The `value: null` POST also deletes // the server draft row so `is_draft` clears on refetch. - initialStates[ws] = $state.snapshot(s) as VariableState - existedInitially[ws] = true - UserDraft.discard('variable', editPath ?? '', s, { workspace: ws }) + UserDraft.discard('variable', from ?? '', s, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) } - sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`) + sendUserToast( + edit ? `Updated variable in ${payloads.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 ?? from - if (savedPath && savedPath !== editPath) editPath = savedPath - drawer?.closeDrawer() + // Only while this editor is still the one that was saved: re-pointed, the + // baseline and path below are the variable it moved to. + if (editPath === from) { + for (const { ws, s } of payloads) { + initialStates[ws] = s + existedInitially[ws] = true + } + // 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. + if (savedPath && savedPath !== editPath) editPath = savedPath + drawer?.closeDrawer() + } onSaved?.(savedPath, from) } 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 5175acd86b..c101a912d5 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -20,6 +20,7 @@ parsePreviewSelectedId, showsView } from './previewRouter' + import type { EntityEditorKind } from './previewRouter' import type { EntityToolEffect } from './previewReload' import { withMenuHidden } from './sessionMode.svelte' import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte' @@ -179,15 +180,13 @@ : undefined ) - // An editor reports a removal or a rename after the write it awaited, and the tab - // it was mounted for may have moved on in the meantime — retargeted onto another - // item, or onto a different page entirely. The report belongs to the item it - // started on, so both callbacks below act only while the tab is still showing it: - // otherwise they would re-point whatever the tab holds now, and build the URL out - // of that unrelated location. - function stillShowing(path: string): boolean { + // An editor reports a removal or a rename after the write it awaited, by which + // time the tab may hold something else entirely. The report is about the item it + // started on, so acting on anything else re-points an unrelated editor — and + // builds the destination out of its location. + function stillShowing(kind: EntityEditorKind, path: string): boolean { const now = resolvePreviewTab(tab.url) - return now.kind === 'entity' && now.path === path + return now.kind === 'entity' && now.entityKind === kind && now.path === path } // The item is gone (a draft-only one whose draft was discarded), so the tab has @@ -196,8 +195,8 @@ // another tab, which must not be the one sent to the list. const returnToList = $derived( slot.kind === 'entity' && runtime - ? (fromPath: string) => { - if (!stillShowing(fromPath)) return + ? (kind: EntityEditorKind, fromPath: string) => { + if (!stillShowing(kind, fromPath)) return runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) } : undefined @@ -208,8 +207,8 @@ // 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, fromPath: string) => { - if (!stillShowing(fromPath)) return + ? (kind: EntityEditorKind, newPath: string, fromPath: string) => { + if (!stillShowing(kind, fromPath)) return runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath)) } : undefined @@ -403,8 +402,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={returnToList} - onRenamed={retargetTo} + onRemoved={(from) => returnToList?.('trigger_schedule', from)} + onRenamed={(to, from) => retargetTo?.('trigger_schedule', to, from)} /> {/await} {:else if slot.entityKind === 'resource'} @@ -415,8 +414,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={returnToList} - onRenamed={retargetTo} + onRemoved={(from) => returnToList?.('resource', from)} + onRenamed={(to, from) => retargetTo?.('resource', to, from)} /> {/await} {:else if slot.entityKind === 'variable'} @@ -427,8 +426,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={returnToList} - onRenamed={retargetTo} + onRemoved={(from) => returnToList?.('variable', from)} + onRenamed={(to, from) => retargetTo?.('variable', to, from)} /> {/await} {/if} diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 599c8ac8cd..f4bf042ac8 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -73,6 +73,10 @@ showDraftBanner {onRemoved} onUpdate={(saved: string | undefined, from: string) => { + // The write landed after the tab was pointed at another schedule: the + // remount below would take that one back through a load it never asked + // for, and the rename is not its rename. + if (from !== path) return generation++ // A draft-only schedule opens with its path editable (saving CREATEs), // so the save can land somewhere other than where the tab is pointed. diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index b02b68e65c..c99275edce 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1467,11 +1467,12 @@ getCurrent={() => draftSync.current} onDiscard={async () => { // The path the discard started from: it awaits a reload of the runnable, - // and an inline host can re-point the editor in the meantime. + // and an inline host can re-point the editor in the meantime — after + // which this discard's outcome is no longer about what is on screen. const from = initialPath const wasDraftOnly = draftOnly await draftSync.resetToDeployed(from) - if (wasDraftOnly && from) onRemoved?.(from) + if (wasDraftOnly && from && initialPath === from) onRemoved?.(from) }} disabled={!can_write} /> From 8202413bb95d001abbae8a0172b02effe3507aaa Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 03:02:34 +0200 Subject: [PATCH 17/69] fix(sessions): mount a hosted entity editor per item, and scope its reports to its workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule and variable views re-pointed one live editor at each new path, so the previous item's load — `openEdit`, `editVariable` and everything they await — finished into the new item's form, baseline, draft cell and permissions, last response winning. Keying each on its path gives every item its own instance, with nothing of the next one's to land on. The resource host already worked this way. A report's guard also matched kind and path but not workspace, so re-scoping an unsent session left a rename from the old workspace free to retarget the new one's tab onto a path that exists only where it was written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 26 +++++++------ .../src/lib/components/VariableEditor.svelte | 15 +++---- .../components/sessions/PreviewTabHost.svelte | 29 ++++++++------ .../sessions/ResourceEditorView.svelte | 8 ++-- .../sessions/ScheduleEditorView.svelte | 29 +++++++------- .../sessions/VariableEditorView.svelte | 39 +++++++++++-------- .../schedules/ScheduleEditorInner.svelte | 13 ++++--- 7 files changed, 87 insertions(+), 72 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index ec86e9e4c1..16d67fd7ca 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -32,10 +32,10 @@ disableChatOffset?: boolean onRestored?: () => 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 — and the one it was opened - * on. For a caller showing state derived from the resource; `onRestored` only - * covers restoring a version. */ - onSaved?: (savedPath?: string, fromPath?: string) => void + * one it was opened on when the user renamed it — and the workspace and path + * it started on. For a caller showing state derived from the resource; + * `onRestored` only covers restoring a version. */ + onSaved?: (savedPath?: string, fromPath?: string, fromWorkspace?: 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 @@ -47,9 +47,9 @@ * user should be able to get back to (a session tab that took over the list). */ onBack?: () => void /** The resource is gone — a draft-only one whose draft was discarded — with - * the path it was showing. A host addressing it by path (a session tab) has - * to stop showing it. */ - onRemoved?: (fromPath: string) => void + * the workspace and path it was showing. A host addressing it by those (a + * session tab) has to stop showing it. */ + onRemoved?: (fromPath: string, fromWorkspace: string) => void } = $props() let drawer: Drawer | undefined = $state() @@ -180,7 +180,8 @@ getCurrent={() => resourceEditor?.localDraftCurrent()} onDiscard={() => { const from = path - if (resourceEditor?.discardLocalDraft() === false && from) onRemoved?.(from) + if (resourceEditor?.discardLocalDraft() === false && from) + onRemoved?.(from, effectiveWorkspace) }} disabled={!canWriteSelected} /> @@ -213,10 +214,11 @@ unifiedSize="md" startIcon={{ icon: Save }} on:click={async () => { - // The path this save started from. Read before the await: an inline host can - // re-point the editor at another resource while the write is in flight, and - // the caller needs to know which one the result belongs to. + // Where this save started. Read before the await: an inline host can re-point + // the editor at another resource, or re-scope the session to another + // workspace, while the write is in flight. const from = path + const fromWs = effectiveWorkspace // 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. @@ -236,7 +238,7 @@ const savedPath = livePath ?? path if (savedPath) path = savedPath editorGeneration++ - onSaved?.(savedPath, from) + onSaved?.(savedPath, from, fromWs) }} disabled={!canSave} > diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index d555e725b3..954ffcd85e 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -58,12 +58,12 @@ * 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 — and the one it was opened on. */ - onSaved?: (savedPath?: string, fromPath?: string) => void + * opened on when the user renamed it — and the workspace and path it started on. */ + onSaved?: (savedPath?: string, fromPath?: string, fromWorkspace?: string) => void /** The variable is gone — a draft-only one whose draft was discarded — with the - * path it was showing. A host addressing it by path (a session tab) has to stop - * showing it. */ - onRemoved?: (fromPath: string) => void + * workspace and path it was showing. A host addressing it by those (a session + * tab) has to stop showing it. */ + onRemoved?: (fromPath: string, fromWorkspace: string) => void } = $props() let curWs = $derived(workspace ?? $workspaceStore) @@ -282,6 +282,7 @@ // would then be that variable's: the writes would send its state under this // one's path, and the baseline below would overwrite its own. const from = editPath + const fromWs = curWs const savedPath = current?.path ?? from const payloads = dirtyWorkspaces.map((ws) => ({ ws, @@ -346,7 +347,7 @@ if (savedPath && savedPath !== editPath) editPath = savedPath drawer?.closeDrawer() } - onSaved?.(savedPath, from) + onSaved?.(savedPath, from, fromWs) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) } @@ -368,7 +369,7 @@ // A draft-only variable has no deployed row under the draft, so discarding // it removed the variable: `initialStates` holds a synthesized stand-in, // not a baseline to fall back to. - if (!existedInitially[selected] && from) onRemoved?.(from) + if (!existedInitially[selected] && from && curWs) onRemoved?.(from, curWs) }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index c101a912d5..09f11a5d2b 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -184,9 +184,14 @@ // time the tab may hold something else entirely. The report is about the item it // started on, so acting on anything else re-points an unrelated editor — and // builds the destination out of its location. - function stillShowing(kind: EntityEditorKind, path: string): boolean { + function stillShowing(kind: EntityEditorKind, path: string, ws: string): boolean { const now = resolvePreviewTab(tab.url) - return now.kind === 'entity' && now.entityKind === kind && now.path === path + return ( + now.kind === 'entity' && + now.entityKind === kind && + now.path === path && + ws === workspaceId + ) } // The item is gone (a draft-only one whose draft was discarded), so the tab has @@ -195,8 +200,8 @@ // another tab, which must not be the one sent to the list. const returnToList = $derived( slot.kind === 'entity' && runtime - ? (kind: EntityEditorKind, fromPath: string) => { - if (!stillShowing(kind, fromPath)) return + ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => { + if (!stillShowing(kind, fromPath, fromWs)) return runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) } : undefined @@ -207,8 +212,8 @@ // path — so they have to move with it, or they name an item that no longer exists. const retargetTo = $derived( slot.kind === 'entity' && runtime - ? (kind: EntityEditorKind, newPath: string, fromPath: string) => { - if (!stillShowing(kind, fromPath)) return + ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => { + if (!stillShowing(kind, fromPath, fromWs)) return runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath)) } : undefined @@ -402,8 +407,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from) => returnToList?.('trigger_schedule', from)} - onRenamed={(to, from) => retargetTo?.('trigger_schedule', to, from)} + onRemoved={(from, ws) => returnToList?.('trigger_schedule', from, ws)} + onRenamed={(to, from, ws) => retargetTo?.('trigger_schedule', to, from, ws)} /> {/await} {:else if slot.entityKind === 'resource'} @@ -414,8 +419,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from) => returnToList?.('resource', from)} - onRenamed={(to, from) => retargetTo?.('resource', to, from)} + onRemoved={(from, ws) => returnToList?.('resource', from, ws)} + onRenamed={(to, from, ws) => retargetTo?.('resource', to, from, ws)} /> {/await} {:else if slot.entityKind === 'variable'} @@ -426,8 +431,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from) => returnToList?.('variable', from)} - onRenamed={(to, from) => retargetTo?.('variable', to, from)} + onRemoved={(from, ws) => returnToList?.('variable', from, ws)} + onRenamed={(to, from, ws) => retargetTo?.('variable', to, from, ws)} /> {/await} {/if} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index d645d16a02..8c7e68015a 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -19,12 +19,12 @@ /** The item at `fromPath` is gone — a draft-only one whose draft was discarded. * Distinct from `onBack`, which moves whichever tab is active: only this tab is * the one to send back, and only while it still shows that item. */ - onRemoved?: (fromPath: string) => void + onRemoved?: (fromPath: string, fromWorkspace: string) => void /** The item at `fromPath` 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, fromPath: string) => void + onRenamed?: (newPath: string, fromPath: string, fromWorkspace: string) => void } = $props() let editor = $state() @@ -45,7 +45,7 @@ workspace={workspaceId} {onBack} {onRemoved} - onSaved={(saved, from) => { - if (saved && from && saved !== from) onRenamed?.(saved, from) + onSaved={(saved, from, fromWs) => { + if (saved && from && fromWs && saved !== from) onRenamed?.(saved, from, fromWs) }} /> diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index f4bf042ac8..41ee6de9de 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -23,12 +23,12 @@ * Distinct from `onBack`, which moves whichever tab is active: the discard * awaits a reload of the runnable, by which time the user may be looking at * another tab, or have pointed this one somewhere else. */ - onRemoved?: (fromPath: string) => void + onRemoved?: (fromPath: string, fromWorkspace: string) => void /** The item at `fromPath` 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, fromPath: string) => void + onRenamed?: (newPath: string, fromPath: string, fromWorkspace: string) => void } = $props() // Captured at init, so it must read the current prop rather than close over it. @@ -42,18 +42,14 @@ // 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 - // config — so the tab needs no knowledge of the target beyond the path. + // Loads the schedule this tab holds, on the instance the `{#key}` below just + // mounted for it — `editor` is rebound per instance, so this re-runs per path + // and per generation. `isFlow` is a first guess only — loadScheduleCfg sets it + // from the loaded config — so the tab needs no knowledge beyond the path. $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)) + if (!path || !e) return + untrack(() => void e.openEdit(path, false)) }) @@ -61,7 +57,10 @@ - {#key generation} + + {#key `${path}#${generation}`} +{#key path} + { + if (saved && from && fromWs && saved !== from) onRenamed?.(saved, from, fromWs) + }} + /> +{/key} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index c99275edce..f484f94b43 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -70,8 +70,8 @@ // session's schedule tab — opts in, or nothing says an edit is unsaved. showDraftBanner = false, // The schedule is gone — a draft-only one whose draft the banner discarded — - // with the path it was showing. A host addressing it by path (a session tab) - // has to stop showing it. + // with the workspace and path it was showing. A host addressing it by those + // (a session tab) has to stop showing it. onRemoved = undefined } = $props() @@ -615,12 +615,13 @@ async function scheduleScript(): Promise { const previousPath = initialPath + const previousWs = wsId const scheduleCfg = getScheduleCfg() deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { draftSync.discard(previousPath, scheduleCfg) - onUpdate?.(scheduleCfg.path, previousPath) + onUpdate?.(scheduleCfg.path, previousPath, previousWs) drawer?.closeDrawer() } deploymentLoading = false @@ -719,6 +720,7 @@ async function handleToggleEnabled(nEnabled: boolean) { const previousEnabled = enabled const path = initialPath + const ws = wsId enabled = nEnabled if (!trigger?.draftConfig) { const ok = await withForkConflictRetry( @@ -735,7 +737,7 @@ return } sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) - onUpdate?.(path, path) + onUpdate?.(path, path, ws) } } @@ -1470,9 +1472,10 @@ // and an inline host can re-point the editor in the meantime — after // which this discard's outcome is no longer about what is on screen. const from = initialPath + const fromWs = wsId const wasDraftOnly = draftOnly await draftSync.resetToDeployed(from) - if (wasDraftOnly && from && initialPath === from) onRemoved?.(from) + if (wasDraftOnly && from && fromWs && initialPath === from) onRemoved?.(from, fromWs) }} disabled={!can_write} /> From 8df960ebd21997cd986e2de580194d3c1187fa27 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 03:15:36 +0200 Subject: [PATCH 18/69] docs(sessions): trim three comments and drop a claim the per-item keying made false Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/ResourceEditorDrawer.svelte | 9 ++++----- .../src/lib/components/sessions/PreviewTabHost.svelte | 6 +++--- .../lib/components/sessions/ScheduleEditorView.svelte | 9 ++++----- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 16d67fd7ca..4675d02455 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -145,11 +145,10 @@ {#snippet editorBody()} - + {#key `${path ?? ''}#${editorGeneration}`} {#await import('./ResourceEditor.svelte')} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 09f11a5d2b..2005e49e82 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -207,9 +207,9 @@ : 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. + // Follow a rename: the tab, its label, the chat's ACTIVE PREVIEW and the draft + // key all address the item 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 ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => { diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 41ee6de9de..43450dd9d0 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -61,11 +61,10 @@ live editor would leave the previous schedule's load to finish into the new one's form and deployed baseline. --> {#key `${path}#${generation}`} - + Date: Sun, 6 Sep 2026 03:32:53 +0200 Subject: [PATCH 19/69] fix(editors): keep an edit made while a save is in flight, and land baselines per workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forms stay editable during a save, and the post-write reset dropped the local draft back to the state that was sent — so anything typed in between vanished when the request returned. The draft is now dropped only while it still holds what was written; an edit on top of the save survives as the unsaved change it is, and the hosts that remount pick it back up. A multi-workspace variable save also adopted its baselines only after every write had succeeded, so a later workspace throwing left an earlier one's draft dropped below a baseline that never moved: dirty forever, and retried as a create it had already made. Each lands as its own write does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 10 ++++++--- .../src/lib/components/VariableEditor.svelte | 21 ++++++++++++------- .../schedules/ScheduleEditorInner.svelte | 14 ++++++++++++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index ddea758bf4..9920df30c9 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -366,10 +366,14 @@ }) } // Reset the handle to the new deployed baseline via `discard`, not - // `remove`. See VariableEditor for the full rationale. - initialStates[ws] = $state.snapshot(s) as ResourceState + // `remove`. See VariableEditor for the full rationale — including why the + // cell is only dropped while it still holds what was written. + const written = $state.snapshot(s) as ResourceState + initialStates[ws] = written existedInitially[ws] = true - UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) + if (draftValuesEqual(states[ws]?.draft, written)) { + UserDraft.discard('resource', initialPath ?? '', written, { workspace: ws }) + } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 954ffcd85e..379b13532e 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -322,11 +322,22 @@ } }) } + // Per workspace, as each write lands: a later one throwing must not leave + // this one's draft dropped below a baseline that never moved, which reads + // as dirty and retries the create it already made. + if (editPath === from) { + initialStates[ws] = s + existedInitially[ws] = true + } // The just-saved state is the new deployed baseline; reset the // handle to it via `discard` (not `remove` — blanking the cell to // `undefined` reads as dirty). The `value: null` POST also deletes - // the server draft row so `is_draft` clears on refetch. - UserDraft.discard('variable', from ?? '', s, { workspace: ws }) + // the server draft row so `is_draft` clears on refetch. Only while the + // cell still holds what was written: the form stays editable during the + // request, and an edit made then is a change on top of the save. + if (draftValuesEqual(states[ws]?.draft, s)) { + UserDraft.discard('variable', from ?? '', s, { workspace: ws }) + } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -336,12 +347,8 @@ ) dispatch('create') // Only while this editor is still the one that was saved: re-pointed, the - // baseline and path below are the variable it moved to. + // path below is the variable it moved to. if (editPath === from) { - for (const { ws, s } of payloads) { - initialStates[ws] = s - existedInitially[ws] = true - } // 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. if (savedPath && savedPath !== editPath) editPath = savedPath diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index f484f94b43..040a5ad47e 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -44,6 +44,8 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' + import { deepEqual } from 'fast-equals' + import { normalizeDraftForCompare } from '$lib/userDraft.svelte' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -620,7 +622,17 @@ deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { - draftSync.discard(previousPath, scheduleCfg) + // Drop the local draft only while the form still holds what was saved: it + // stays editable during the request, and an edit made then is a change on + // top of the save, which the reset would silently swallow. + if ( + deepEqual( + normalizeDraftForCompare($state.snapshot(getScheduleCfg())), + normalizeDraftForCompare(scheduleCfg) + ) + ) { + draftSync.discard(previousPath, scheduleCfg) + } onUpdate?.(scheduleCfg.path, previousPath, previousWs) drawer?.closeDrawer() } From 67c1faed532c05dea2505f144fd88dd035bd3678 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 04:07:20 +0200 Subject: [PATCH 20/69] fix(editors): read what a save sends before it is sent, and settle its draft in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resource save read its snapshot back out of the live form after the request returned, so the "did anything change meanwhile" test compared the edit against itself and always said no — the mid-request edit became the deployed baseline instead. Its payloads, its submitted path and the schedule's config are now all pinned before the first await, which also makes the schedule's test see edits to `args`, `retry` and `labels` rather than only its scalar fields. The three copies of the settling rule become `settleDraftAfterWrite`. A save that moved the item resets the cell rather than keeping a diverged one: the edit cannot follow — a freshly acquired cell reads only its own default — and the alternative is a draft stranded under a path the item no longer occupies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 42 ++++++---- .../components/ResourceEditorDrawer.svelte | 6 +- .../src/lib/components/VariableEditor.svelte | 23 +++--- .../schedules/ScheduleEditorInner.svelte | 27 ++++--- frontend/src/lib/userDraft.svelte.ts | 22 ++++++ .../src/lib/userDraftSettleAfterWrite.test.ts | 78 +++++++++++++++++++ 6 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/userDraftSettleAfterWrite.test.ts diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 9920df30c9..59685fdeba 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -12,7 +12,12 @@ import { resource } from 'runed' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' - import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' + import { + UserDraft, + draftValuesEqual, + settleDraftAfterWrite, + type UserDraftHandle + } from '$lib/userDraft.svelte' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' interface Props { @@ -331,12 +336,21 @@ /** Whether the write landed. It toasts its own failure, so most callers ignore this; * one that follows the save with bookkeeping of its own has to know not to. */ export async function save(): Promise { - const dirty = dirtyWorkspaces + // Everything the writes send, read before the first await. The form stays + // editable while they are in flight, so read later these would be whatever + // the user has since typed — sent under an earlier workspace's path, and + // adopted as a baseline the server never saw. + const from = initialPath ?? '' + const payloads = dirtyWorkspaces.map((ws) => ({ + ws, + s: $state.snapshot(states[ws].draft!) as ResourceState, + ini: $state.snapshot(initialStates[ws]) as ResourceState, + existed: !!existedInitially[ws] + })) + const savedPath = payloads[0]?.s.path ?? from try { - for (const ws of dirty) { - const s = states[ws].draft! - const ini = initialStates[ws] - if (existedInitially[ws]) { + for (const { ws, s, ini, existed } of payloads) { + if (existed) { await ResourceService.updateResource({ workspace: ws, path: ini.path, @@ -366,22 +380,20 @@ }) } // Reset the handle to the new deployed baseline via `discard`, not - // `remove`. See VariableEditor for the full rationale — including why the - // cell is only dropped while it still holds what was written. - const written = $state.snapshot(s) as ResourceState - initialStates[ws] = written + // `remove`. See VariableEditor for the full rationale. + initialStates[ws] = s existedInitially[ws] = true - if (draftValuesEqual(states[ws]?.draft, written)) { - UserDraft.discard('resource', initialPath ?? '', written, { workspace: ws }) - } + settleDraftAfterWrite('resource', s, states[ws]?.draft, from, savedPath, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) } sendUserToast( - dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource` + payloads.length > 1 + ? `Saved resource in ${payloads.length} workspaces` + : `Saved resource` ) - dispatch('refresh', current?.path ?? path) + dispatch('refresh', savedPath) return true } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 4675d02455..c0f9add493 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -218,6 +218,10 @@ // workspace, while the write is in flight. const from = path const fromWs = effectiveWorkspace + // The path the form holds now is the one `save()` is about to send; read + // after the await it would be a rename the user typed meanwhile, and the + // tab would follow to a path this write never created. + const submitted = livePath ?? path // 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. @@ -234,7 +238,7 @@ // 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 + const savedPath = submitted if (savedPath) path = savedPath editorGeneration++ onSaved?.(savedPath, from, fromWs) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 379b13532e..557dca8def 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -22,7 +22,12 @@ import { resource } from 'runed' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' - import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' + import { + UserDraft, + draftValuesEqual, + settleDraftAfterWrite, + type UserDraftHandle + } from '$lib/userDraft.svelte' import LocalDraftBanner from './LocalDraftBanner.svelte' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' @@ -329,15 +334,13 @@ initialStates[ws] = s existedInitially[ws] = true } - // The just-saved state is the new deployed baseline; reset the - // handle to it via `discard` (not `remove` — blanking the cell to - // `undefined` reads as dirty). The `value: null` POST also deletes - // the server draft row so `is_draft` clears on refetch. Only while the - // cell still holds what was written: the form stays editable during the - // request, and an edit made then is a change on top of the save. - if (draftValuesEqual(states[ws]?.draft, s)) { - UserDraft.discard('variable', from ?? '', s, { workspace: ws }) - } + // The just-saved state is the new deployed baseline; `settleDraftAfterWrite` + // resets the handle to it via `discard` (not `remove` — blanking the cell + // to `undefined` reads as dirty), and carries a mid-request edit onto the + // path the save wrote to. + settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', savedPath ?? '', { + workspace: ws + }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 040a5ad47e..0e7d51d070 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -44,8 +44,7 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' - import { deepEqual } from 'fast-equals' - import { normalizeDraftForCompare } from '$lib/userDraft.svelte' + import { settleDraftAfterWrite } from '$lib/userDraft.svelte' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -618,21 +617,21 @@ async function scheduleScript(): Promise { const previousPath = initialPath const previousWs = wsId - const scheduleCfg = getScheduleCfg() + // Snapshotted, not just built: the object is fresh but its `args`, `retry` and + // `labels` alias live state, so an edit made while the request is in flight + // would read back as part of what was sent. + const scheduleCfg = $state.snapshot(getScheduleCfg()) as Record deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { - // Drop the local draft only while the form still holds what was saved: it - // stays editable during the request, and an edit made then is a change on - // top of the save, which the reset would silently swallow. - if ( - deepEqual( - normalizeDraftForCompare($state.snapshot(getScheduleCfg())), - normalizeDraftForCompare(scheduleCfg) - ) - ) { - draftSync.discard(previousPath, scheduleCfg) - } + settleDraftAfterWrite( + 'trigger_schedule', + scheduleCfg, + getScheduleCfg(), + previousPath, + scheduleCfg.path, + { workspace: previousWs ?? undefined } + ) onUpdate?.(scheduleCfg.path, previousPath, previousWs) drawer?.closeDrawer() } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 1fc0aafaac..699eb4717c 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -268,6 +268,28 @@ export function draftValuesEqual(a: unknown, b: unknown): boolean { return deepEqual(normalizeDraftForCompare(a), normalizeDraftForCompare(b)) } +/** + * Settle a draft cell after the write of `written` — the value actually sent — + * landed. A form stays editable while its request is in flight, so `live` may + * hold a newer edit; that is a change on top of the save rather than part of it, + * and resetting the cell would swallow it silently. A save that also moved the + * item is the exception: the edit cannot follow (a freshly acquired cell reads + * only its own default), so the cell is reset rather than left orphaned under a + * path the item no longer occupies. + */ +export function settleDraftAfterWrite( + itemKind: UserDraftItemKind, + written: V, + live: V | undefined, + fromPath: string, + savedPath: string, + opts?: UserDraftOptions +): void { + if (savedPath !== fromPath || draftValuesEqual(live, written)) { + UserDraft.discard(itemKind, fromPath, written, opts) + } +} + export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts new file mode 100644 index 0000000000..039629f8da --- /dev/null +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const discard = vi.fn() +vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } })) +vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) +vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) + +import { settleDraftAfterWrite, UserDraft } from './userDraft.svelte' + +beforeEach(() => { + discard.mockClear() + vi.spyOn(UserDraft, 'discard').mockImplementation(discard as any) +}) + +const OPTS = { workspace: 'ws' } +const sent = { path: 'u/me/a', value: 'sent' } + +/** + * The forms stay editable while their save is in flight, so the cell can hold a + * newer edit by the time the write returns. Resetting it unconditionally — what + * every editor did — swallows that edit with no trace. + */ +describe('settleDraftAfterWrite', () => { + it('resets the cell when it still holds what was written', () => { + settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/a', OPTS) + expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) + }) + + it('keeps an edit made while the write was in flight', () => { + settleDraftAfterWrite( + 'variable', + sent, + { path: 'u/me/a', value: 'typed' }, + 'u/me/a', + 'u/me/a', + OPTS + ) + expect(discard).not.toHaveBeenCalled() + }) + + // A renamed save is the exception: a freshly acquired cell reads only its own + // default, so the edit cannot follow the item and the alternative to resetting + // is an orphan draft under a path the item no longer occupies. + it('resets even a diverged cell when the write moved the item', () => { + settleDraftAfterWrite( + 'variable', + sent, + { path: 'u/me/a', value: 'typed' }, + 'u/me/a', + 'u/me/b', + OPTS + ) + expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) + }) + + it('compares through the draft normalization, so a nested edit is not missed', () => { + const written = { path: 'u/me/a', args: { host: 'h' } } + settleDraftAfterWrite( + 'resource', + written, + { path: 'u/me/a', args: { host: 'h' } }, + 'u/me/a', + 'u/me/a', + OPTS + ) + expect(discard).toHaveBeenCalledTimes(1) + discard.mockClear() + settleDraftAfterWrite( + 'resource', + written, + { path: 'u/me/a', args: { host: 'edited' } }, + 'u/me/a', + 'u/me/a', + OPTS + ) + expect(discard).not.toHaveBeenCalled() + }) +}) From 1381536ec638f0273e417b54b310a287b19e675c Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 04:36:18 +0200 Subject: [PATCH 21/69] fix(editors): report a removal only once its delete lands, and a rename only from the acting workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A discard queues its delete through the autosave debouncer and returns, so the host left for the list while the row was still there — and left just the same when the delete failed outright. The three callbacks now wait for it and report removal only when it landed; a delete that didn't means the item is still there. `WsSpecificVersions` can point a form at a linked workspace, where a rename is that workspace's alone. Both editors took the saved path from whichever version was selected and handed it to a host acting on another one, and settled every workspace's draft against that single path. The reported path now comes from the acting workspace's own write, and each workspace settles against its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 32 ++++++++++------- .../components/ResourceEditorDrawer.svelte | 34 ++++++++----------- .../src/lib/components/VariableEditor.svelte | 28 +++++++++------ .../schedules/ScheduleEditorInner.svelte | 10 ++++-- frontend/src/lib/userDraft.svelte.ts | 19 +++++++++++ 5 files changed, 79 insertions(+), 44 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 59685fdeba..15154f1573 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -15,6 +15,7 @@ import { UserDraft, draftValuesEqual, + flushDraftDelete, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -299,12 +300,13 @@ * that was never deployed removes the resource itself — `initialStates` holds a * synthesized stand-in, not a baseline to fall back to — so a caller showing it * has to stop rather than keep displaying that stand-in. */ - export function discardLocalDraft(): boolean { + export async function discardLocalDraft(): Promise { if (!selected) return true - UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { - workspace: selected - }) - return !!existedInitially[selected] + const ws = selected + UserDraft.discard('resource', initialPath ?? '', initialStates[ws], { workspace: ws }) + // A delete that never landed leaves the resource where it was. + const landed = await flushDraftDelete('resource', initialPath ?? '', { workspace: ws }) + return !!existedInitially[ws] || !landed } $effect(() => { @@ -333,9 +335,10 @@ current.path = npath } - /** Whether the write landed. It toasts its own failure, so most callers ignore this; - * one that follows the save with bookkeeping of its own has to know not to. */ - export async function save(): Promise { + /** The path the acting workspace's write landed on, or undefined if it failed — + * it toasts its own failure, so a caller only needs this to know whether to run + * bookkeeping of its own, and where the item ended up. */ + export async function save(): Promise { // Everything the writes send, read before the first await. The form stays // editable while they are in flight, so read later these would be whatever // the user has since typed — sent under an earlier workspace's path, and @@ -347,7 +350,10 @@ ini: $state.snapshot(initialStates[ws]) as ResourceState, existed: !!existedInitially[ws] })) - const savedPath = payloads[0]?.s.path ?? from + // The path the ACTING workspace's write used. `WsSpecificVersions` can point + // the form at a linked workspace, and a rename made there is that workspace's + // alone — reporting it would move a host that is looking at this one. + const savedPath = payloads.find((pl) => pl.ws === effectiveWorkspace)?.s.path ?? from try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -383,7 +389,9 @@ // `remove`. See VariableEditor for the full rationale. initialStates[ws] = s existedInitially[ws] = true - settleDraftAfterWrite('resource', s, states[ws]?.draft, from, savedPath, { workspace: ws }) + // `s.path`, not the reported one: each workspace settles against the path + // its own write used. + settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -394,10 +402,10 @@ : `Saved resource` ) dispatch('refresh', savedPath) - return true + return savedPath } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) - return false + return undefined } } diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index c0f9add493..d16c5afe32 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -63,18 +63,16 @@ let resourceEditor: | { - /** False when the write failed; it toasts its own error. */ - save: () => Promise + /** The path it wrote to, or undefined when it failed; it toasts its own error. */ + save: () => Promise localDraftDeployed: () => unknown localDraftCurrent: () => unknown /** False when the discard removed the resource (it was draft-only). */ - discardLocalDraft: () => boolean + discardLocalDraft: () => Promise } | 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) @@ -163,7 +161,6 @@ bind:canSave bind:selected bind:viewJsonSchema - onChange={(e) => (livePath = e.path)} onDraftStateChange={(v) => (hasLocalDraft = v)} onCanWriteChange={(v) => (canWriteSelected = v)} /> @@ -177,10 +174,11 @@ reserveSpace={mode == 'edit'} getDeployed={() => resourceEditor?.localDraftDeployed()} getCurrent={() => resourceEditor?.localDraftCurrent()} - onDiscard={() => { + onDiscard={async () => { const from = path - if (resourceEditor?.discardLocalDraft() === false && from) - onRemoved?.(from, effectiveWorkspace) + const fromWs = effectiveWorkspace + if ((await resourceEditor?.discardLocalDraft()) === false && from) + onRemoved?.(from, fromWs) }} disabled={!canWriteSelected} /> @@ -218,22 +216,20 @@ // workspace, while the write is in flight. const from = path const fromWs = effectiveWorkspace - // The path the form holds now is the one `save()` is about to send; read - // after the await it would be a rename the user typed meanwhile, and the - // tab would follow to a path this write never created. - const submitted = livePath ?? path // 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 saving = resourceEditor?.save() drawer?.closeDrawer() - // Everything below moves this host onto the path that was written, so it - // must not run for a write that failed — a rejected rename (a name - // collision, say) would point the tab at a path this save never created. - if (!(await saving)) return + // The path the write landed on, from the editor rather than the form: the form + // may be showing a linked workspace's variant, whose rename is not this host's. + // Undefined means the write failed — a rejected rename (a name collision, say) + // — and everything below would move this host onto a path it never created. + const submitted = await saving + if (!submitted) return // An inline host re-pointed this editor while the write was in flight, so - // `path`, `livePath` and the mounted editor are another resource's now. - // Moving any of them onto this write's result would move that one instead. + // `path` and the mounted editor are another resource's now. Moving either + // onto this write's result would move that one instead. if (path !== from) return // Rendered inline there is no drawer to close, so the mounted editor would // otherwise keep the pre-save baseline. Follow a rename before remounting, diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 557dca8def..f1c3c267f1 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -25,6 +25,7 @@ import { UserDraft, draftValuesEqual, + flushDraftDelete, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -288,13 +289,16 @@ // one's path, and the baseline below would overwrite its own. const from = editPath const fromWs = curWs - const savedPath = current?.path ?? from const payloads = dirtyWorkspaces.map((ws) => ({ ws, s: $state.snapshot(states[ws].draft!) as VariableState, ini: $state.snapshot(initialStates[ws]) as VariableState, existed: !!existedInitially[ws] })) + // The path the ACTING workspace's write used. `WsSpecificVersions` can point + // the form at a linked workspace, and a rename made there is that workspace's + // alone — reporting it would move a host that is looking at this one. + const savedPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -336,9 +340,9 @@ } // The just-saved state is the new deployed baseline; `settleDraftAfterWrite` // resets the handle to it via `discard` (not `remove` — blanking the cell - // to `undefined` reads as dirty), and carries a mid-request edit onto the - // path the save wrote to. - settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', savedPath ?? '', { + // to `undefined` reads as dirty), and keeps an edit made mid-request. Each + // workspace settles against the path its own write used, not the reported one. + settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so @@ -370,16 +374,18 @@ reserveSpace={edit} getDeployed={() => (selected ? initialStates[selected] : undefined)} getCurrent={() => current} - onDiscard={() => { + onDiscard={async () => { if (!selected) return + const ws = selected const from = editPath ?? '' - UserDraft.discard('variable', from, initialStates[selected], { - workspace: selected - }) + const fromWs = curWs + UserDraft.discard('variable', from, initialStates[ws], { workspace: ws }) // A draft-only variable has no deployed row under the draft, so discarding - // it removed the variable: `initialStates` holds a synthesized stand-in, - // not a baseline to fall back to. - if (!existedInitially[selected] && from && curWs) onRemoved?.(from, curWs) + // it removed the variable: `initialStates` holds a synthesized stand-in, not + // a baseline to fall back to. Only once the delete has landed — until then + // the variable is still there, and a host would leave on a row it can see. + const landed = await flushDraftDelete('variable', from, { workspace: ws }) + if (!existedInitially[ws] && landed && from && fromWs) onRemoved?.(from, fromWs) }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 0e7d51d070..0399030862 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -44,7 +44,7 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' - import { settleDraftAfterWrite } from '$lib/userDraft.svelte' + import { flushDraftDelete, settleDraftAfterWrite } from '$lib/userDraft.svelte' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -1486,7 +1486,13 @@ const fromWs = wsId const wasDraftOnly = draftOnly await draftSync.resetToDeployed(from) - if (wasDraftOnly && from && fromWs && initialPath === from) onRemoved?.(from, fromWs) + // Only once the delete has landed: until then the schedule is still + // there, and a host would leave on a row it can see. + const landed = await flushDraftDelete('trigger_schedule', from, { + workspace: fromWs ?? undefined + }) + if (wasDraftOnly && landed && from && fromWs && initialPath === from) + onRemoved?.(from, fromWs) }} disabled={!can_write} /> diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 699eb4717c..d90d3bb434 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -290,6 +290,25 @@ export function settleDraftAfterWrite( } } +/** + * Wait for a queued draft delete to actually land, reporting whether it did. The + * delete rides the autosave debouncer, so a caller that acts on it — a host that + * leaves an editor whose item the discard removed — would otherwise navigate onto + * a row that is still there, or away from a delete that failed. + */ +export async function flushDraftDelete( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions +): Promise { + const query = { workspace: resolveWorkspace(opts), itemKind, path } + await UserDraftDbSyncer.flush(query) + return ( + UserDraftDbSyncer.getState(query).state !== 'failed' && + UserDraftDbSyncer.getConflict(query).conflict === undefined + ) +} + export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) From 83cd40a6dd665792a6958cb92dc43181fbcd2d3b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 04:55:50 +0200 Subject: [PATCH 22/69] fix(editors): confirm a draft is gone from what landed, and follow a rename that committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discard verdict read the sync pipeline's own state, which settles whichever write it ends up sending: the form stays editable after Discard, so typing then replaces the queued delete with an upsert and the pipeline reports success for it. The draft-state hint is written by the POST that actually landed, so it answers what the pipeline cannot — whether the draft is still there. A multi-workspace save writes in sequence and a later workspace throwing aborts the rest, but what the acting workspace wrote is deployed either way. Both saves now report that path, so a host is never left pointing at one the item has already moved off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 8 +++- .../src/lib/components/VariableEditor.svelte | 27 +++++++---- frontend/src/lib/userDraft.svelte.ts | 20 ++++---- frontend/src/lib/userDraftFlushDelete.test.ts | 48 +++++++++++++++++++ 4 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/userDraftFlushDelete.test.ts diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 15154f1573..14cc726873 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -354,6 +354,11 @@ // the form at a linked workspace, and a rename made there is that workspace's // alone — reporting it would move a host that is looking at this one. const savedPath = payloads.find((pl) => pl.ws === effectiveWorkspace)?.s.path ?? from + // Set once the acting workspace's own write has committed. The workspaces are + // written in sequence and a later one throwing aborts the rest, but what this + // one wrote is already deployed — a caller told nothing would stay pointed at + // a path it has moved off. + let committed: string | undefined = undefined try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -389,6 +394,7 @@ // `remove`. See VariableEditor for the full rationale. initialStates[ws] = s existedInitially[ws] = true + if (ws === effectiveWorkspace) committed = s.path // `s.path`, not the reported one: each workspace settles against the path // its own write used. settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws }) @@ -405,7 +411,7 @@ return savedPath } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) - return undefined + return committed } } diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index f1c3c267f1..24c8548d45 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -299,6 +299,21 @@ // the form at a linked workspace, and a rename made there is that workspace's // alone — reporting it would move a host that is looking at this one. const savedPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from + // Set once the acting workspace's own write has committed. The workspaces are + // written in sequence and a later one throwing aborts the rest, but what this + // one wrote is already deployed — a host told nothing would stay pointed at a + // path it has moved off. + let committed: string | undefined = undefined + // Follow the rename locally and tell the host, for whatever committed. Guarded + // on this editor still being the one that was saved: re-pointed, `editPath` is + // the variable it moved to. + const reportSaved = (saved: string | undefined) => { + if (editPath === from) { + if (saved && saved !== editPath) editPath = saved + drawer?.closeDrawer() + } + onSaved?.(saved, from, fromWs) + } try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -342,6 +357,7 @@ // resets the handle to it via `discard` (not `remove` — blanking the cell // to `undefined` reads as dirty), and keeps an edit made mid-request. Each // workspace settles against the path its own write used, not the reported one. + if (ws === fromWs) committed = s.path settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) @@ -353,17 +369,10 @@ edit ? `Updated variable in ${payloads.length} workspace(s)` : `Created variable` ) dispatch('create') - // Only while this editor is still the one that was saved: re-pointed, the - // path below is the variable it moved to. - if (editPath === from) { - // 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. - if (savedPath && savedPath !== editPath) editPath = savedPath - drawer?.closeDrawer() - } - onSaved?.(savedPath, from, fromWs) + reportSaved(savedPath) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) + if (committed) reportSaved(committed) } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index d90d3bb434..0e2354a96a 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -4,6 +4,7 @@ import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { readFieldsRecursively } from './utils' import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' +import { getLocalDraftHint } from './localDraftHints.svelte' import type { UserDraftItemKind } from './gen' export type { UserDraftItemKind } @@ -291,22 +292,21 @@ export function settleDraftAfterWrite( } /** - * Wait for a queued draft delete to actually land, reporting whether it did. The - * delete rides the autosave debouncer, so a caller that acts on it — a host that - * leaves an editor whose item the discard removed — would otherwise navigate onto - * a row that is still there, or away from a delete that failed. + * Wait for a queued draft delete to actually land, reporting whether the draft is + * gone. A caller acts on this — a host leaves an editor whose item the discard + * removed — so it has to be the outcome, not the pipeline's state: the delete + * shares its queue with the edits, and the form stays editable after Discard, so + * it can be displaced by a newer upsert as easily as it can fail. The hint is + * written by whichever POST landed, which is what tells the two apart. */ export async function flushDraftDelete( itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions ): Promise { - const query = { workspace: resolveWorkspace(opts), itemKind, path } - await UserDraftDbSyncer.flush(query) - return ( - UserDraftDbSyncer.getState(query).state !== 'failed' && - UserDraftDbSyncer.getConflict(query).conflict === undefined - ) + const workspace = resolveWorkspace(opts) + await UserDraftDbSyncer.flush({ workspace, itemKind, path }) + return getLocalDraftHint(workspace, itemKind, path) === false } export type UserDraftHandle = { diff --git a/frontend/src/lib/userDraftFlushDelete.test.ts b/frontend/src/lib/userDraftFlushDelete.test.ts new file mode 100644 index 0000000000..d80c4b0ebb --- /dev/null +++ b/frontend/src/lib/userDraftFlushDelete.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The flush stands in for the POST it drives: whatever write actually lands is +// what publishes the hint, which is the only thing that tells a delete apart +// from an edit that displaced it. +let landed: 'delete' | 'upsert' | 'nothing' = 'delete' +vi.mock('./userDraftDbSyncer.svelte', () => ({ + UserDraftDbSyncer: { + flush: vi.fn(async ({ workspace, itemKind, path }: any) => { + if (landed === 'nothing') return + const { setLocalDraftHint } = await import('./localDraftHints.svelte') + setLocalDraftHint(workspace, itemKind, path, landed === 'upsert') + }), + save: vi.fn() + } +})) +vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } })) +vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) + +import { flushDraftDelete } from './userDraft.svelte' + +let n = 0 +let path = '' +beforeEach(() => { + // A fresh key per case: hints persist by design, so a reused one would carry + // the previous case's answer. + path = `u/me/v${n++}` +}) + +describe('flushDraftDelete', () => { + it('confirms a delete that landed', async () => { + landed = 'delete' + expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(true) + }) + + // Discard leaves the form editable, so typing after it can replace the queued + // `value: null` with an upsert. The pipeline settles either way; only the item + // still being there tells the caller not to leave the editor. + it('rejects a delete displaced by a later edit', async () => { + landed = 'upsert' + expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(false) + }) + + it('rejects a delete that never landed', async () => { + landed = 'nothing' + expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(false) + }) +}) From 7a7e30de8772b3c9b550cb5413550e3f5bf7c962 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 05:19:32 +0200 Subject: [PATCH 23/69] fix(drafts): decide a delete landed from the response handler, and from nothing being queued behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft hint has a second writer: every editor clears it from an effect as soon as a workspace sits at its baseline, which a discard does synchronously — so a delete that then failed read as one that landed, and the host left on an item still there. `postSave` now records which payload last landed for a key, a fact only it can write, and the verdict reads that. A delete winning is also not enough on its own. The form stays editable while the flush is in flight and a flush only submits what it read at its start, so an edit made in between sits on the debouncer and would recreate the draft behind the host's back. The key has to be idle too. The variable drawer no longer closes when a save failed partway: that is the case where the workspace still needing one is retried from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 3 + .../src/lib/components/VariableEditor.svelte | 14 ++-- frontend/src/lib/userDraft.svelte.ts | 22 ++++--- frontend/src/lib/userDraftDbSyncer.svelte.ts | 20 ++++++ frontend/src/lib/userDraftFlushDelete.test.ts | 64 +++++++++++-------- 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 14cc726873..123e78591c 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -411,6 +411,9 @@ return savedPath } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) + // The workspaces that did not get this far keep their drafts under the path + // they still hold the item at; re-keying them onto this rename would move a + // path they never wrote. return committed } } diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 24c8548d45..edb0e4e8cc 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -306,11 +306,12 @@ let committed: string | undefined = undefined // Follow the rename locally and tell the host, for whatever committed. Guarded // on this editor still being the one that was saved: re-pointed, `editPath` is - // the variable it moved to. - const reportSaved = (saved: string | undefined) => { + // the variable it moved to. `close` only when every workspace is done — after a + // partial failure the drawer is where the one that still needs saving is retried. + const reportSaved = (saved: string | undefined, close: boolean) => { if (editPath === from) { if (saved && saved !== editPath) editPath = saved - drawer?.closeDrawer() + if (close) drawer?.closeDrawer() } onSaved?.(saved, from, fromWs) } @@ -369,10 +370,13 @@ edit ? `Updated variable in ${payloads.length} workspace(s)` : `Created variable` ) dispatch('create') - reportSaved(savedPath) + reportSaved(savedPath, true) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) - if (committed) reportSaved(committed) + // The workspaces that did not get this far keep their drafts under the path + // they still hold the item at; re-keying them onto this rename would move a + // path they never wrote. They stay listed, and retryable from here. + if (committed) reportSaved(committed, false) } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 0e2354a96a..44975c0865 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -4,7 +4,6 @@ import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { readFieldsRecursively } from './utils' import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' -import { getLocalDraftHint } from './localDraftHints.svelte' import type { UserDraftItemKind } from './gen' export type { UserDraftItemKind } @@ -292,21 +291,24 @@ export function settleDraftAfterWrite( } /** - * Wait for a queued draft delete to actually land, reporting whether the draft is - * gone. A caller acts on this — a host leaves an editor whose item the discard - * removed — so it has to be the outcome, not the pipeline's state: the delete - * shares its queue with the edits, and the form stays editable after Discard, so - * it can be displaced by a newer upsert as easily as it can fail. The hint is - * written by whichever POST landed, which is what tells the two apart. + * Wait for a queued draft delete to land, reporting whether the draft is gone. A + * caller acts on this — a host leaves an editor whose item the discard removed — + * so both halves have to hold: the delete is the last thing that landed, and + * nothing is queued behind it. The form stays editable after Discard, and a + * `flush` only submits the payload it read when it started, so an edit made in + * between rides the debouncer and would recreate the draft after the caller left. */ export async function flushDraftDelete( itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions ): Promise { - const workspace = resolveWorkspace(opts) - await UserDraftDbSyncer.flush({ workspace, itemKind, path }) - return getLocalDraftHint(workspace, itemKind, path) === false + const query = { workspace: resolveWorkspace(opts), itemKind, path } + await UserDraftDbSyncer.flush(query) + return ( + UserDraftDbSyncer.lastLandedWasDelete(query) && + UserDraftDbSyncer.getState(query).state === 'none' + ) } export type UserDraftHandle = { diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 0e8257023d..1718700f07 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -228,6 +228,16 @@ const failures = new SvelteMap() */ const flushes = new SvelteMap() +/** + * Whether the last save that LANDED for a key deleted the draft or wrote one. + * Written only here, by the response handler, which is what distinguishes it + * from the display-level draft hint: that one is also published optimistically + * and by the editors themselves, so it cannot say whether a POST succeeded. + * A caller acting on a delete (leaving an editor whose item it removed) needs + * the difference. Never cleared — "last landed" is meaningless until one has. + */ +const lastLanded = new Map() + /** * Per-key listeners fired when a save for that key LANDS on the server * (`status === 'saved'` with a non-null value — the draft now exists @@ -316,6 +326,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { // (value !== null → exists). Every delete path clears the hint for // free instead of maintaining a separate source of truth. setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null) + lastLanded.set(key, opts.value === null ? 'delete' : 'upsert') conflicts.delete(key) failures.delete(key) // Clear pending only if it's still the opts we just saved — a @@ -594,6 +605,15 @@ export const UserDraftDbSyncer = { }, /** Reactive conflict snapshot (if any) for a draft. */ + /** + * Whether the last save that landed for this key was the draft's deletion. + * False while none has landed at all — a failed or conflicted delete never + * reaches the response handler, so it never claims to have landed. + */ + lastLandedWasDelete(query: UserDraftLastSyncQuery): boolean { + return lastLanded.get(draftKey(query.workspace, query.itemKind, query.path)) === 'delete' + }, + getConflict(query: UserDraftLastSyncQuery): { readonly conflict: DraftConflictInfo | undefined } { diff --git a/frontend/src/lib/userDraftFlushDelete.test.ts b/frontend/src/lib/userDraftFlushDelete.test.ts index d80c4b0ebb..5868cd581a 100644 --- a/frontend/src/lib/userDraftFlushDelete.test.ts +++ b/frontend/src/lib/userDraftFlushDelete.test.ts @@ -1,48 +1,60 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -// The flush stands in for the POST it drives: whatever write actually lands is -// what publishes the hint, which is the only thing that tells a delete apart -// from an edit that displaced it. -let landed: 'delete' | 'upsert' | 'nothing' = 'delete' +// Stands in for the syncer's per-key facts. `landed` is what the response +// handler recorded; `state` is what is still queued behind it. +let landedDelete = false +let state: 'none' | 'pending' | 'saving' | 'failed' = 'none' vi.mock('./userDraftDbSyncer.svelte', () => ({ UserDraftDbSyncer: { - flush: vi.fn(async ({ workspace, itemKind, path }: any) => { - if (landed === 'nothing') return - const { setLocalDraftHint } = await import('./localDraftHints.svelte') - setLocalDraftHint(workspace, itemKind, path, landed === 'upsert') - }), + flush: vi.fn(async () => {}), + lastLandedWasDelete: vi.fn(() => landedDelete), + getState: vi.fn(() => ({ state })), save: vi.fn() } })) vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } })) vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) +vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) import { flushDraftDelete } from './userDraft.svelte' -let n = 0 -let path = '' +const run = () => flushDraftDelete('variable', 'u/me/v', { workspace: 'ws' }) + beforeEach(() => { - // A fresh key per case: hints persist by design, so a reused one would carry - // the previous case's answer. - path = `u/me/v${n++}` + landedDelete = false + state = 'none' }) +/** + * A caller leaves an editor on this verdict, so anything short of "the draft is + * gone and staying gone" has to read false. + */ describe('flushDraftDelete', () => { - it('confirms a delete that landed', async () => { - landed = 'delete' - expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(true) + it('confirms a delete that landed with nothing behind it', async () => { + landedDelete = true + expect(await run()).toBe(true) }) - // Discard leaves the form editable, so typing after it can replace the queued - // `value: null` with an upsert. The pipeline settles either way; only the item - // still being there tells the caller not to leave the editor. - it('rejects a delete displaced by a later edit', async () => { - landed = 'upsert' - expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(false) + // The form stays editable after Discard and `flush` only submits what it read + // when it started, so an edit made in between is still queued — and would + // recreate the draft once the caller had already left. + it('rejects a delete with an edit queued behind it', async () => { + landedDelete = true + state = 'pending' + expect(await run()).toBe(false) }) - it('rejects a delete that never landed', async () => { - landed = 'nothing' - expect(await flushDraftDelete('variable', path, { workspace: 'ws' })).toBe(false) + // A failed delete never reaches the response handler, so nothing records it as + // landed — the display hint would have said otherwise, since the editor + // publishes that one itself the moment the cell returns to its baseline. + it('rejects a delete that failed', async () => { + landedDelete = false + state = 'failed' + expect(await run()).toBe(false) + }) + + it('rejects a key where an upsert landed last', async () => { + landedDelete = false + expect(await run()).toBe(false) }) }) From 9d7b06ecc47a5174ec53251b360cf5e3104acad7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 05:39:17 +0200 Subject: [PATCH 24/69] fix(drafts): stop the landed-delete fact from outliving the server state it described MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fact is sticky by design, so a delete that landed earlier for a key kept answering after the server moved out from under it: a discard that conflicted left it standing, and the conflict itself is tracked apart from the state the verdict reads, so both said the draft was gone. It is dropped now wherever the server stops being ours to describe — on a conflict, and on a remote resync. Also corrects what the partial-save comments claim. Following the committed rename re-keys every workspace's handle, so a workspace whose write never happened does lose sight of its draft in this editor; the draft is still on the server under the path that workspace holds the item at, which is where it stays reachable. Saying it stayed reachable here was wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 7 ++-- .../src/lib/components/VariableEditor.svelte | 7 ++-- frontend/src/lib/userDraftDbSyncer.svelte.ts | 13 +++++-- frontend/src/lib/userDraftLastLanded.test.ts | 37 +++++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/userDraftLastLanded.test.ts diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 123e78591c..b077cc412a 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -411,9 +411,10 @@ return savedPath } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) - // The workspaces that did not get this far keep their drafts under the path - // they still hold the item at; re-keying them onto this rename would move a - // path they never wrote. + // Following the rename remounts on the new path, so a workspace whose write + // never happened loses sight of its draft here — it stays on the server under + // the path that workspace still holds the item at, listed and editable from + // there. Re-keying it instead would move a path it never wrote. return committed } } diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index edb0e4e8cc..ca313ea632 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -373,9 +373,10 @@ reportSaved(savedPath, true) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) - // The workspaces that did not get this far keep their drafts under the path - // they still hold the item at; re-keying them onto this rename would move a - // path they never wrote. They stay listed, and retryable from here. + // Following the rename re-keys every workspace's handle, so a workspace whose + // write never happened loses sight of its draft here — it stays on the server + // under the path that workspace still holds the item at, listed and editable + // from there. Re-keying it instead would move a path it never wrote. if (committed) reportSaved(committed, false) } } diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 1718700f07..2a7dd6db50 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -313,6 +313,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { serverTimestamp: resp.current_timestamp, localLastSync: lastSync ?? null }) + lastLanded.delete(key) return } // resp.status === 'saved' — advance lastSync (or drop on delete). @@ -546,9 +547,11 @@ export const UserDraftDbSyncer = { } else { clearLastSync(query.workspace, query.itemKind, query.path) } - // Back in sync with the server: clear any conflict / failure. + // Back in sync with the server: clear any conflict / failure. `lastLanded` + // goes with them — whatever we last wrote no longer describes what is there. conflicts.delete(key) failures.delete(key) + lastLanded.delete(key) }, /** @@ -604,16 +607,18 @@ export const UserDraftDbSyncer = { } }, - /** Reactive conflict snapshot (if any) for a draft. */ /** * Whether the last save that landed for this key was the draft's deletion. - * False while none has landed at all — a failed or conflicted delete never - * reaches the response handler, so it never claims to have landed. + * False while none has landed — a delete that failed or conflicted never + * reaches the response handler, so it never claims to have landed, and one + * that landed earlier is dropped as soon as the server's state moves outside + * our writes. */ lastLandedWasDelete(query: UserDraftLastSyncQuery): boolean { return lastLanded.get(draftKey(query.workspace, query.itemKind, query.path)) === 'delete' }, + /** Reactive conflict snapshot (if any) for a draft. */ getConflict(query: UserDraftLastSyncQuery): { readonly conflict: DraftConflictInfo | undefined } { diff --git a/frontend/src/lib/userDraftLastLanded.test.ts b/frontend/src/lib/userDraftLastLanded.test.ts new file mode 100644 index 0000000000..fd030d79b0 --- /dev/null +++ b/frontend/src/lib/userDraftLastLanded.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest' + +let response: { status: 'saved' | 'conflict'; current_timestamp: string } = { + status: 'saved', + current_timestamp: '2020-01-01T00:00:00Z' +} +const updateDraft = vi.fn(async (..._args: any[]) => response) +vi.mock('./gen', () => ({ + DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) } +})) +vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) +vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) + +import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' + +const query = { workspace: 'ws', itemKind: 'variable' as const, path: 'u/me/v' } +const send = (value: unknown) => UserDraftDbSyncer.save({ ...query, value, immediate: true }) + +/** + * `flushDraftDelete` leaves an editor on this fact, and it is deliberately + * sticky — so it has to stop describing the server the moment the server stops + * being ours to describe. + */ +describe('lastLandedWasDelete', () => { + it('follows what the response handler saw land, and drops it on a conflict', async () => { + await send({ path: 'u/me/v' }) + expect(UserDraftDbSyncer.lastLandedWasDelete(query)).toBe(false) + + await send(null) + expect(UserDraftDbSyncer.lastLandedWasDelete(query)).toBe(true) + + // The server moved under us: the earlier delete no longer answers for this one. + response = { status: 'conflict', current_timestamp: '2020-01-02T00:00:00Z' } + await send(null) + expect(UserDraftDbSyncer.lastLandedWasDelete(query)).toBe(false) + }) +}) From 345f40affb5c3f33c8c4c964ad01b1340b426f2d Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 06:18:45 +0200 Subject: [PATCH 25/69] fix(sessions): move every tab on an item the editor renamed or removed, not just its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warm sessions share one draft cell and one server row per item, so a rename or a draft-only discard made in one left the others editing a path that is gone. The editor's report now goes to the page, which re-points every tab on that item in that workspace — the same fan-out the chat tools already use. Matching on the item is also what the host's own staleness check was for: a report that lands after its tab has been re-pointed simply finds nothing to match, so `stillShowing` goes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/sessions/PreviewTabHost.svelte | 58 ++++++++----------- frontend/src/lib/userDraftDbSyncer.svelte.ts | 18 +++--- .../(root)/(logged)/sessions/+page.svelte | 30 ++++++++++ 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 2005e49e82..9216baeff6 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -12,7 +12,6 @@ import type { SessionRuntime } from './sessionRuntime.svelte' import { Loader2 } from 'lucide-svelte' import { - entityEditorHref, entityListHref, entityListPage, resolvePreviewTab, @@ -37,7 +36,8 @@ darkMode, fullscreen = false, onNavigate, - onLoad + onLoad, + onEntityMoved }: { tab: SessionPreviewTab session: Session | undefined @@ -63,6 +63,14 @@ onNavigate: (item: WorkspaceItem) => void /** Iframe finished loading — the page reads back its observed location. */ onLoad: (frame: HTMLIFrameElement) => void + /** A hosted entity editor moved its item: renamed it (`to`) or removed it. + * The page re-points every tab on that item, across warm sessions. */ + onEntityMoved: (ev: { + kind: EntityEditorKind + path: string + workspace: string + to?: string + }) => void } = $props() // Editor vs iframe is decided purely from the tab URL (see resolvePreviewTab): @@ -180,42 +188,26 @@ : undefined ) - // An editor reports a removal or a rename after the write it awaited, by which - // time the tab may hold something else entirely. The report is about the item it - // started on, so acting on anything else re-points an unrelated editor — and - // builds the destination out of its location. - function stillShowing(kind: EntityEditorKind, path: string, ws: string): boolean { - const now = resolvePreviewTab(tab.url) - return ( - now.kind === 'entity' && - now.entityKind === kind && - now.path === path && - ws === workspaceId - ) - } - - // The item is gone (a draft-only one whose draft was discarded), so the tab has - // the same destination as `backToList` — but bound to this tab by id, not to - // whichever is active: by the time a discard lands the user may be looking at - // another tab, which must not be the one sent to the list. + // An editor reports a removal or a rename about the item it started on, after + // the write it awaited — by which time this tab may hold something else, and + // other warm sessions may hold that same item. Both are the page's to resolve: + // it re-points every tab still on the item, in the workspace it was written in, + // which is also the only way the reporting tab is spared a report gone stale. + // A removal has no destination path; a rename carries the one it moved to. const returnToList = $derived( - slot.kind === 'entity' && runtime - ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => { - if (!stillShowing(kind, fromPath, fromWs)) return - runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) - } + slot.kind === 'entity' + ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => + onEntityMoved({ kind, path: fromPath, workspace: fromWs }) : undefined ) - // Follow a rename: the tab, its label, the chat's ACTIVE PREVIEW and the draft - // key all address the item by path, so they have to move with it or they name an - // item that no longer exists. + // The tab, its label, the chat's ACTIVE PREVIEW and the draft key all address + // the item 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 - ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => { - if (!stillShowing(kind, fromPath, fromWs)) return - runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath)) - } + slot.kind === 'entity' + ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => + onEntityMoved({ kind, path: fromPath, workspace: fromWs, to: newPath }) : undefined ) diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 2a7dd6db50..85842ca881 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -230,11 +230,12 @@ const flushes = new SvelteMap() /** * Whether the last save that LANDED for a key deleted the draft or wrote one. - * Written only here, by the response handler, which is what distinguishes it - * from the display-level draft hint: that one is also published optimistically - * and by the editors themselves, so it cannot say whether a POST succeeded. - * A caller acting on a delete (leaving an editor whose item it removed) needs - * the difference. Never cleared — "last landed" is meaningless until one has. + * Written only by the response handler, which is what distinguishes it from the + * display-level draft hint: that one is also published optimistically and by the + * editors themselves, so it cannot say whether a POST succeeded. A caller acting + * on a delete (leaving an editor whose item it removed) needs the difference. + * Dropped when the server stops being ours to describe — a conflict, or a resync + * from a load — and otherwise kept, since the key's own next landing replaces it. */ const lastLanded = new Map() @@ -609,10 +610,9 @@ export const UserDraftDbSyncer = { /** * Whether the last save that landed for this key was the draft's deletion. - * False while none has landed — a delete that failed or conflicted never - * reaches the response handler, so it never claims to have landed, and one - * that landed earlier is dropped as soon as the server's state moves outside - * our writes. + * False while none has landed: a delete that failed or conflicted never + * reaches the response handler, and a conflict drops what an earlier one + * recorded rather than letting it answer for this attempt. */ lastLandedWasDelete(query: UserDraftLastSyncQuery): boolean { return lastLanded.get(draftKey(query.workspace, query.itemKind, query.path)) === 'delete' diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index fd675ad7be..92dbefb2cf 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -64,8 +64,11 @@ matchPreviewPage, pageKey, parseArtifactRoute, + entityEditorHref, entityKindForPage, entityListHref, + entityListPage, + type EntityEditorKind, parseEntityEditorRoute, parsePreviewItemRoute, previewLocationLabel, @@ -608,6 +611,32 @@ } } } + // A hosted entity editor moved its item — renamed it, or removed it by + // discarding the draft that was all of it. Every tab on that item follows, + // across warm sessions: they share the one draft cell and the one server row, + // so a tab left behind edits a path that is gone. Matching on the item is also + // what keeps a report that landed late from moving a tab that has since been + // re-pointed — there is nothing to match. + function moveEntityTabs(ev: { + kind: EntityEditorKind + path: string + workspace: string + to?: string + }) { + const page = entityListPage(ev.kind)?.path + if (!page) return + for (const s of warmSessions) { + const owner = getRuntime(s.id)?.previewTabs + if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue + for (const tab of owner.tabs) { + const loc = whereIs(tab) + const entity = parseEntityEditorRoute(loc) + if (!entity || entity.path !== ev.path || stripBase(loc) !== page) continue + // The tab's own location, so the list it came from keeps its filters. + owner.retargetTabTo(tab.id, ev.to ? entityEditorHref(loc, ev.to) : entityListHref(loc)) + } + } + } function flushReload() { const pages = pendingPages const mutations = pendingMutations @@ -1160,6 +1189,7 @@ {fullscreen} onNavigate={navigateEditorTo} onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} + onEntityMoved={moveEntityTabs} /> {/each} {/each} From 21ca77e415b1d139d04d8246e6f752f0661ac10c Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 06:48:17 +0200 Subject: [PATCH 26/69] fix(sessions): tell every tab on an item about any save, not only one that moved it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editors on the same item share its draft cell but each keeps its own deployed baseline, so a save made in one left the others offering to discard back to the value it replaced — over a deployment. A save now reports the path it wrote to whether or not that moved the item, and the tabs on it that stayed put are re-read instead of re-pointed. The variable drawer reopens on the committed path after a partial multi-workspace failure rather than re-keying: re-keying brought its handles back from the values the drawer opened on, which describe neither what the committed workspace has deployed nor the draft the failed one still holds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/VariableEditor.svelte | 35 +++++++-------- .../components/sessions/PreviewTabHost.svelte | 43 ++++++++----------- .../sessions/ResourceEditorView.svelte | 14 +++--- .../sessions/ScheduleEditorView.svelte | 18 ++++---- .../sessions/VariableEditorView.svelte | 14 +++--- .../(root)/(logged)/sessions/+page.svelte | 20 +++++---- 6 files changed, 71 insertions(+), 73 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index ca313ea632..0dea6612e5 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -304,17 +304,6 @@ // one wrote is already deployed — a host told nothing would stay pointed at a // path it has moved off. let committed: string | undefined = undefined - // Follow the rename locally and tell the host, for whatever committed. Guarded - // on this editor still being the one that was saved: re-pointed, `editPath` is - // the variable it moved to. `close` only when every workspace is done — after a - // partial failure the drawer is where the one that still needs saving is retried. - const reportSaved = (saved: string | undefined, close: boolean) => { - if (editPath === from) { - if (saved && saved !== editPath) editPath = saved - if (close) drawer?.closeDrawer() - } - onSaved?.(saved, from, fromWs) - } try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -370,14 +359,26 @@ edit ? `Updated variable in ${payloads.length} workspace(s)` : `Created variable` ) dispatch('create') - reportSaved(savedPath, true) + // Only while this editor is still the one that was saved: re-pointed, + // `editPath` is the variable it moved to. + if (editPath === from) { + // 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. + if (savedPath && savedPath !== editPath) editPath = savedPath + drawer?.closeDrawer() + } + onSaved?.(savedPath, from, fromWs) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) - // Following the rename re-keys every workspace's handle, so a workspace whose - // write never happened loses sight of its draft here — it stays on the server - // under the path that workspace still holds the item at, listed and editable - // from there. Re-keying it instead would move a path it never wrote. - if (committed) reportSaved(committed, false) + if (committed) { + onSaved?.(committed, from, fromWs) + // Reopened, not re-keyed: re-keying would bring the handles back from the + // values this drawer opened on, which describe neither what the committed + // workspace now has deployed nor the draft the failed one still holds at + // its own path. That draft stays there, listed and editable from its + // workspace — moving it onto a rename it never wrote would be worse. + if (editPath === from) editVariable(committed) + } } } diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 9216baeff6..a59cb4041e 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -37,7 +37,7 @@ fullscreen = false, onNavigate, onLoad, - onEntityMoved + onEntityWritten }: { tab: SessionPreviewTab session: Session | undefined @@ -63,9 +63,10 @@ onNavigate: (item: WorkspaceItem) => void /** Iframe finished loading — the page reads back its observed location. */ onLoad: (frame: HTMLIFrameElement) => void - /** A hosted entity editor moved its item: renamed it (`to`) or removed it. - * The page re-points every tab on that item, across warm sessions. */ - onEntityMoved: (ev: { + /** A hosted entity editor wrote its item: saved it (`to`, the path it wrote + * to) or removed it. The page reaches every tab on that item, across warm + * sessions. */ + onEntityWritten: (ev: { kind: EntityEditorKind path: string workspace: string @@ -188,26 +189,20 @@ : undefined ) - // An editor reports a removal or a rename about the item it started on, after - // the write it awaited — by which time this tab may hold something else, and - // other warm sessions may hold that same item. Both are the page's to resolve: - // it re-points every tab still on the item, in the workspace it was written in, - // which is also the only way the reporting tab is spared a report gone stale. - // A removal has no destination path; a rename carries the one it moved to. - const returnToList = $derived( + // What an editor reports is about an item, not about this tab: other warm + // sessions can hold the same one, and by the time the write it awaited returns + // this tab may hold something else. The page resolves both by matching on the + // item, so a report gone stale simply finds nothing. + const reportRemoved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => - onEntityMoved({ kind, path: fromPath, workspace: fromWs }) + onEntityWritten({ kind, path: fromPath, workspace: fromWs }) : undefined ) - - // The tab, its label, the chat's ACTIVE PREVIEW and the draft key all address - // the item by path, so they have to move with it or they name an item that no - // longer exists. - const retargetTo = $derived( + const reportSaved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => - onEntityMoved({ kind, path: fromPath, workspace: fromWs, to: newPath }) + onEntityWritten({ kind, path: fromPath, workspace: fromWs, to: newPath }) : undefined ) @@ -399,8 +394,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from, ws) => returnToList?.('trigger_schedule', from, ws)} - onRenamed={(to, from, ws) => retargetTo?.('trigger_schedule', to, from, ws)} + onRemoved={(from, ws) => reportRemoved?.('trigger_schedule', from, ws)} + onSavedTo={(to, from, ws) => reportSaved?.('trigger_schedule', to, from, ws)} /> {/await} {:else if slot.entityKind === 'resource'} @@ -411,8 +406,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from, ws) => returnToList?.('resource', from, ws)} - onRenamed={(to, from, ws) => retargetTo?.('resource', to, from, ws)} + onRemoved={(from, ws) => reportRemoved?.('resource', from, ws)} + onSavedTo={(to, from, ws) => reportSaved?.('resource', to, from, ws)} /> {/await} {:else if slot.entityKind === 'variable'} @@ -423,8 +418,8 @@ path={slot.path} {workspaceId} onBack={backToList} - onRemoved={(from, ws) => returnToList?.('variable', from, ws)} - onRenamed={(to, from, ws) => retargetTo?.('variable', to, from, ws)} + onRemoved={(from, ws) => reportRemoved?.('variable', from, ws)} + onSavedTo={(to, from, ws) => reportSaved?.('variable', to, from, ws)} /> {/await} {/if} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 8c7e68015a..cb9b98976a 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -7,7 +7,7 @@ workspaceId, onBack, onRemoved, - onRenamed + onSavedTo }: { /** The resource this tab edits (the row its location deep-links). */ path: string @@ -20,11 +20,11 @@ * Distinct from `onBack`, which moves whichever tab is active: only this tab is * the one to send back, and only while it still shows that item. */ onRemoved?: (fromPath: string, fromWorkspace: string) => void - /** The item at `fromPath` 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, fromPath: string, fromWorkspace: string) => void + /** The item at `fromPath` was saved, to `newPath`. Every tab on it has to hear: + * a rename moves the ones addressing it by path — their label, the chat's + * ACTIVE PREVIEW and the draft key all do — and a plain save moves none but + * leaves the others holding a baseline the deploy has replaced. */ + onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void } = $props() let editor = $state() @@ -46,6 +46,6 @@ {onBack} {onRemoved} onSaved={(saved, from, fromWs) => { - if (saved && from && fromWs && saved !== from) onRenamed?.(saved, from, fromWs) + if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs) }} /> diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 43450dd9d0..77eafab1ad 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -10,7 +10,7 @@ workspaceId, onBack, onRemoved, - onRenamed + onSavedTo }: { /** The schedule this tab edits (the row its location deep-links). */ path: string @@ -24,11 +24,11 @@ * awaits a reload of the runnable, by which time the user may be looking at * another tab, or have pointed this one somewhere else. */ onRemoved?: (fromPath: string, fromWorkspace: string) => void - /** The item at `fromPath` 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, fromPath: string, fromWorkspace: string) => void + /** The item at `fromPath` was saved, to `newPath`. Every tab on it has to hear: + * a rename moves the ones addressing it by path — their label, the chat's + * ACTIVE PREVIEW and the draft key all do — and a plain save moves none but + * leaves the others holding a baseline the deploy has replaced. */ + onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void } = $props() // Captured at init, so it must read the current prop rather than close over it. @@ -76,9 +76,9 @@ // for, and the rename is not its rename. if (from !== path) return generation++ - // A draft-only schedule opens with its path editable (saving CREATEs), - // so the save can land somewhere other than where the tab is pointed. - if (saved && saved !== from) onRenamed?.(saved, from, fromWs) + // A draft-only schedule opens with its path editable (saving CREATEs), so + // the save can land somewhere other than where the tab is pointed. + if (saved) onSavedTo?.(saved, from, fromWs) }} > {#snippet customLabel()} diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index 07f074653b..33f8762282 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -7,7 +7,7 @@ workspaceId, onBack, onRemoved, - onRenamed + onSavedTo }: { /** The variable this tab edits (the row its location deep-links). */ path: string @@ -20,11 +20,11 @@ * Distinct from `onBack`, which moves whichever tab is active: only this tab is * the one to send back, and only while it still shows that item. */ onRemoved?: (fromPath: string, fromWorkspace: string) => void - /** The item at `fromPath` 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, fromPath: string, fromWorkspace: string) => void + /** The item at `fromPath` was saved, to `newPath`. Every tab on it has to hear: + * a rename moves the ones addressing it by path — their label, the chat's + * ACTIVE PREVIEW and the draft key all do — and a plain save moves none but + * leaves the others holding a baseline the deploy has replaced. */ + onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void } = $props() let editor = $state() @@ -50,7 +50,7 @@ {onBack} {onRemoved} onSaved={(saved, from, fromWs) => { - if (saved && from && fromWs && saved !== from) onRenamed?.(saved, from, fromWs) + if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs) }} /> {/key} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 92dbefb2cf..20d69429c2 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -611,16 +611,14 @@ } } } - // A hosted entity editor moved its item — renamed it, or removed it by - // discarding the draft that was all of it. Every tab on that item follows, - // across warm sessions: they share the one draft cell and the one server row, - // so a tab left behind edits a path that is gone. Matching on the item is also - // what keeps a report that landed late from moving a tab that has since been - // re-pointed — there is nothing to match. - function moveEntityTabs(ev: { + // A hosted entity editor wrote its item. Every tab on it hears, across warm + // sessions: they share the one draft cell and the one server row, so one left + // behind edits a path that is gone or discards over a value already deployed. + function entityWritten(ev: { kind: EntityEditorKind path: string workspace: string + /** The path it wrote to; absent when the write removed the item. */ to?: string }) { const page = entityListPage(ev.kind)?.path @@ -633,7 +631,11 @@ const entity = parseEntityEditorRoute(loc) if (!entity || entity.path !== ev.path || stripBase(loc) !== page) continue // The tab's own location, so the list it came from keeps its filters. - owner.retargetTabTo(tab.id, ev.to ? entityEditorHref(loc, ev.to) : entityListHref(loc)) + if (!ev.to) owner.retargetTabTo(tab.id, entityListHref(loc)) + else if (ev.to !== ev.path) owner.retargetTabTo(tab.id, entityEditorHref(loc, ev.to)) + // Same path: nothing to re-point, but every editor on it holds a baseline + // the deploy has replaced, and would offer to discard back to it. + else tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) } } } @@ -1189,7 +1191,7 @@ {fullscreen} onNavigate={navigateEditorTo} onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} - onEntityMoved={moveEntityTabs} + onEntityWritten={entityWritten} /> {/each} {/each} From c7faaf1956ccfe9894b98f7fa496089fde398beb Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 07:23:13 +0200 Subject: [PATCH 27/69] fix(sessions): report a write per workspace, and leave the reporting editor alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-path refresh reached the tab that did the save, remounting it over the edit its own settle had just preserved — the teardown's flush and the fresh editor's read race, and the older value can win. The reporting tab is skipped: it has settled its own state, which is what the report is telling everyone else. A save can also write several workspaces while only one event went out, labelled with the acting one, and a discard deletes the draft of whichever workspace the form is showing while claiming the acting one — so a linked workspace's tabs kept a replaced baseline and the wrong tabs were closed. Each write now reports its own workspace and the path it wrote there, which retires the acting-workspace bookkeeping that stood in for it. A tab re-pointed mid-write no longer swallows the report either: only the local remount was ever its to suppress. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 33 +++++++------- .../components/ResourceEditorDrawer.svelte | 41 +++++++++-------- .../src/lib/components/VariableEditor.svelte | 45 +++++++++---------- .../components/sessions/PreviewTabHost.svelte | 14 ++++-- .../sessions/ScheduleEditorView.svelte | 12 +++-- .../schedules/ScheduleEditorInner.svelte | 7 ++- .../(root)/(logged)/sessions/+page.svelte | 9 ++-- 7 files changed, 84 insertions(+), 77 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index b077cc412a..36c0ccc065 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -335,10 +335,9 @@ current.path = npath } - /** The path the acting workspace's write landed on, or undefined if it failed — - * it toasts its own failure, so a caller only needs this to know whether to run - * bookkeeping of its own, and where the item ended up. */ - export async function save(): Promise { + /** What the save deployed: one entry per workspace written, with the path it + * wrote there. Empty when nothing landed — it toasts its own failure. */ + export async function save(): Promise<{ ws: string; path: string }[]> { // Everything the writes send, read before the first await. The form stays // editable while they are in flight, so read later these would be whatever // the user has since typed — sent under an earlier workspace's path, and @@ -353,12 +352,11 @@ // The path the ACTING workspace's write used. `WsSpecificVersions` can point // the form at a linked workspace, and a rename made there is that workspace's // alone — reporting it would move a host that is looking at this one. - const savedPath = payloads.find((pl) => pl.ws === effectiveWorkspace)?.s.path ?? from - // Set once the acting workspace's own write has committed. The workspaces are - // written in sequence and a later one throwing aborts the rest, but what this - // one wrote is already deployed — a caller told nothing would stay pointed at - // a path it has moved off. - let committed: string | undefined = undefined + // Every workspace this save wrote, in order, each with the path it wrote there. + // The workspaces go in sequence and a later one throwing aborts the rest, but + // what an earlier one wrote is deployed — a caller told nothing about it would + // stay pointed at a path the item has moved off. + const written: { ws: string; path: string }[] = [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -394,7 +392,7 @@ // `remove`. See VariableEditor for the full rationale. initialStates[ws] = s existedInitially[ws] = true - if (ws === effectiveWorkspace) committed = s.path + written.push({ ws, path: s.path }) // `s.path`, not the reported one: each workspace settles against the path // its own write used. settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws }) @@ -407,15 +405,14 @@ ? `Saved resource in ${payloads.length} workspaces` : `Saved resource` ) - dispatch('refresh', savedPath) - return savedPath + dispatch('refresh', written.find((w) => w.ws === effectiveWorkspace)?.path ?? from) + return written } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) - // Following the rename remounts on the new path, so a workspace whose write - // never happened loses sight of its draft here — it stays on the server under - // the path that workspace still holds the item at, listed and editable from - // there. Re-keying it instead would move a path it never wrote. - return committed + // The workspaces that never got their write keep their drafts at their own + // paths, listed and editable from there — moving one onto a rename it never + // wrote would be worse than not showing it here. + return written } } diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index d16c5afe32..9990280fe7 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -63,8 +63,9 @@ let resourceEditor: | { - /** The path it wrote to, or undefined when it failed; it toasts its own error. */ - save: () => Promise + /** One entry per workspace written, with the path it wrote there; empty when + * nothing landed. It toasts its own error. */ + save: () => Promise<{ ws: string; path: string }[]> localDraftDeployed: () => unknown localDraftCurrent: () => unknown /** False when the discard removed the resource (it was draft-only). */ @@ -176,7 +177,10 @@ getCurrent={() => resourceEditor?.localDraftCurrent()} onDiscard={async () => { const from = path - const fromWs = effectiveWorkspace + // The workspace the editor is showing, which `WsSpecificVersions` can point at + // a linked one: the discard deletes that workspace's draft, so that is whose + // tabs it is about. + const fromWs = selected ?? effectiveWorkspace if ((await resourceEditor?.discardLocalDraft()) === false && from) onRemoved?.(from, fromWs) }} @@ -221,23 +225,24 @@ // caller of this drawer. `onSaved` still fires after the write lands. const saving = resourceEditor?.save() drawer?.closeDrawer() - // The path the write landed on, from the editor rather than the form: the form - // may be showing a linked workspace's variant, whose rename is not this host's. - // Undefined means the write failed — a rejected rename (a name collision, say) - // — and everything below would move this host onto a path it never created. - const submitted = await saving - if (!submitted) return - // An inline host re-pointed this editor while the write was in flight, so - // `path` and the mounted editor are another resource's now. Moving either - // onto this write's result would move that one instead. - if (path !== from) return + // What landed, per workspace, from the editor rather than the form: the form may + // be showing a linked workspace's variant, whose rename is not this host's. + const written = (await saving) ?? [] + if (written.length === 0) return + const submitted = written.find((w) => w.ws === fromWs)?.path // 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 = submitted - if (savedPath) path = savedPath - editorGeneration++ - onSaved?.(savedPath, from, fromWs) + // or it comes back up on a path the save just moved the item off. Only while + // this is still the resource it saved: re-pointed mid-write, `path` and the + // mounted editor are another one's, and moving them would move that one. + if (path === from) { + if (submitted) path = submitted + editorGeneration++ + } + // One report per workspace written, each carrying its own — a linked + // workspace's rename moves the tabs acting on it and no others. Reported + // even when this drawer has moved on: the write is a fact about the item. + for (const w of written) onSaved?.(w.path, from, w.ws) }} disabled={!canSave} > diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 0dea6612e5..163a37aabb 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -295,15 +295,10 @@ ini: $state.snapshot(initialStates[ws]) as VariableState, existed: !!existedInitially[ws] })) - // The path the ACTING workspace's write used. `WsSpecificVersions` can point - // the form at a linked workspace, and a rename made there is that workspace's - // alone — reporting it would move a host that is looking at this one. - const savedPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from - // Set once the acting workspace's own write has committed. The workspaces are - // written in sequence and a later one throwing aborts the rest, but what this - // one wrote is already deployed — a host told nothing would stay pointed at a - // path it has moved off. - let committed: string | undefined = undefined + // What this editor itself follows: `WsSpecificVersions` can point the form at a + // linked workspace, and a rename made there is that workspace's alone. + const actingPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from + let actingCommitted = false try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -347,7 +342,12 @@ // resets the handle to it via `discard` (not `remove` — blanking the cell // to `undefined` reads as dirty), and keeps an edit made mid-request. Each // workspace settles against the path its own write used, not the reported one. - if (ws === fromWs) committed = s.path + if (ws === fromWs) actingCommitted = true + // Per workspace, as each write lands. Each carries its own workspace and + // the path it wrote there, so a linked workspace's rename moves the tabs + // acting on it and no others — and a workspace written before a later one + // threw is still reported, because it is deployed. + onSaved?.(s.path, from, ws) settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) @@ -363,22 +363,17 @@ // `editPath` is the variable it moved to. if (editPath === from) { // 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. - if (savedPath && savedPath !== editPath) editPath = savedPath + // stays mounted, so follow the new path here. + if (actingPath && actingPath !== editPath) editPath = actingPath drawer?.closeDrawer() } - onSaved?.(savedPath, from, fromWs) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) - if (committed) { - onSaved?.(committed, from, fromWs) - // Reopened, not re-keyed: re-keying would bring the handles back from the - // values this drawer opened on, which describe neither what the committed - // workspace now has deployed nor the draft the failed one still holds at - // its own path. That draft stays there, listed and editable from its - // workspace — moving it onto a rename it never wrote would be worse. - if (editPath === from) editVariable(committed) - } + // Reopened, so the handles come from the server: re-keying them would bring + // back the values this drawer opened on, which describe neither what the + // committed workspace now has deployed nor the draft the failed one still + // holds — that one stays at its own path, editable from its workspace. + if (actingCommitted && actingPath && editPath === from) editVariable(actingPath) } } @@ -391,16 +386,18 @@ getCurrent={() => current} onDiscard={async () => { if (!selected) return + // The workspace the editor is showing, which `WsSpecificVersions` can point at + // a linked one: the discard deletes that workspace's draft, so that is whose + // tabs it is about. const ws = selected const from = editPath ?? '' - const fromWs = curWs UserDraft.discard('variable', from, initialStates[ws], { workspace: ws }) // A draft-only variable has no deployed row under the draft, so discarding // it removed the variable: `initialStates` holds a synthesized stand-in, not // a baseline to fall back to. Only once the delete has landed — until then // the variable is still there, and a host would leave on a row it can see. const landed = await flushDraftDelete('variable', from, { workspace: ws }) - if (!existedInitially[ws] && landed && from && fromWs) onRemoved?.(from, fromWs) + if (!existedInitially[ws] && landed && from) onRemoved?.(from, ws) }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index a59cb4041e..e1887fefc6 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -65,12 +65,14 @@ onLoad: (frame: HTMLIFrameElement) => void /** A hosted entity editor wrote its item: saved it (`to`, the path it wrote * to) or removed it. The page reaches every tab on that item, across warm - * sessions. */ + * sessions — `fromTabId` being the one that reported, which has settled its + * own state already. */ onEntityWritten: (ev: { kind: EntityEditorKind path: string workspace: string to?: string + fromTabId: string }) => void } = $props() @@ -196,13 +198,19 @@ const reportRemoved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => - onEntityWritten({ kind, path: fromPath, workspace: fromWs }) + onEntityWritten({ kind, path: fromPath, workspace: fromWs, fromTabId: tab.id }) : undefined ) const reportSaved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => - onEntityWritten({ kind, path: fromPath, workspace: fromWs, to: newPath }) + onEntityWritten({ + kind, + path: fromPath, + workspace: fromWs, + to: newPath, + fromTabId: tab.id + }) : undefined ) diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 77eafab1ad..24cd364d4b 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -71,13 +71,11 @@ showDraftBanner {onRemoved} onUpdate={(saved: string | undefined, from: string, fromWs: string) => { - // The write landed after the tab was pointed at another schedule: the - // remount below would take that one back through a load it never asked - // for, and the rename is not its rename. - if (from !== path) return - generation++ - // A draft-only schedule opens with its path editable (saving CREATEs), so - // the save can land somewhere other than where the tab is pointed. + // Remount only while this tab is still on the schedule that was saved: + // pointed elsewhere since, it would be taken back through a load it never + // asked for. The report goes out either way — the tabs on that schedule + // elsewhere have to hear, and a draft-only save can land on a new path. + if (from === path) generation++ if (saved) onSavedTo?.(saved, from, fromWs) }} > diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 0399030862..297e3ca6ba 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1480,8 +1480,8 @@ getCurrent={() => draftSync.current} onDiscard={async () => { // The path the discard started from: it awaits a reload of the runnable, - // and an inline host can re-point the editor in the meantime — after - // which this discard's outcome is no longer about what is on screen. + // and an inline host can re-point the editor in the meantime. What it + // removed is still that schedule, which is what the report is about. const from = initialPath const fromWs = wsId const wasDraftOnly = draftOnly @@ -1491,8 +1491,7 @@ const landed = await flushDraftDelete('trigger_schedule', from, { workspace: fromWs ?? undefined }) - if (wasDraftOnly && landed && from && fromWs && initialPath === from) - onRemoved?.(from, fromWs) + if (wasDraftOnly && landed && from && fromWs) onRemoved?.(from, fromWs) }} disabled={!can_write} /> diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 20d69429c2..a2027b62b1 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -620,6 +620,7 @@ workspace: string /** The path it wrote to; absent when the write removed the item. */ to?: string + fromTabId: string }) { const page = entityListPage(ev.kind)?.path if (!page) return @@ -633,9 +634,11 @@ // The tab's own location, so the list it came from keeps its filters. if (!ev.to) owner.retargetTabTo(tab.id, entityListHref(loc)) else if (ev.to !== ev.path) owner.retargetTabTo(tab.id, entityEditorHref(loc, ev.to)) - // Same path: nothing to re-point, but every editor on it holds a baseline - // the deploy has replaced, and would offer to discard back to it. - else tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) + // Same path: nothing to re-point, but every OTHER editor on it holds a + // baseline the deploy has replaced and would offer to discard back to it. + // The reporting one has settled its own, including an edit typed while the + // save was in flight, which a remount would re-read from under. + else if (tab.id !== ev.fromTabId) tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) } } } From 0dd7508eb67e1262cb7c3b6d7586d46d25b165a3 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 08:11:09 +0200 Subject: [PATCH 28/69] fix(editors): restore the failed-save guard the widened return broke, and stop remounting over a kept edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `save()` now answers with the workspaces it wrote, and an empty array is truthy — so the MCP section's `if (!(await save())) return` stopped guarding, and a failed save ran the enablement bookkeeping its own comment says must not run. It asks for the length. The page declined to remount the tab that reported a save, but that tab was remounting itself: the resource host on every save, the schedule host likewise. The resource never needed it — its editor adopts the written value as its own baseline — and the schedule remounts only when the save kept no edit, which is the one case where re-reading is what the editor wants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../lib/components/ResourceEditorDrawer.svelte | 16 +++++++--------- .../copilot/chat/AssistantMcpSection.svelte | 4 +++- .../sessions/ScheduleEditorView.svelte | 14 ++++++-------- .../schedules/ScheduleEditorInner.svelte | 16 +++++++++++++--- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 9990280fe7..2a75022349 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -230,15 +230,13 @@ const written = (await saving) ?? [] if (written.length === 0) return const submitted = written.find((w) => w.ws === fromWs)?.path - // 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. Only while - // this is still the resource it saved: re-pointed mid-write, `path` and the - // mounted editor are another one's, and moving them would move that one. - if (path === from) { - if (submitted) path = submitted - editorGeneration++ - } + // Follow a rename: rendered inline there is no drawer to close, so the mounted + // editor stays, and the key below remounts it on the path the item moved to. + // The editor adopts its own new baseline, so a same-path save needs nothing + // here — remounting it would re-read over an edit made during the write. + // Only while this is still the resource it saved: re-pointed mid-write, + // `path` and the mounted editor are another one's. + if (path === from && submitted) path = submitted // One report per workspace written, each carrying its own — a linked // workspace's rename moves the tabs acting on it and no others. Reported // even when this drawer has moved on: the write is a fact about the item. diff --git a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte index 9440aa8f58..24b707756a 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte @@ -233,7 +233,9 @@ switch that decides whether this chat carries its tools. // A failed save leaves the connection exactly as it was, so none of the // bookkeeping below may run: moving the enablement then would turn a server // that still exists off, and turn on a path that was never created. - if (!(await resourceEditor?.save())) return + // `save` reports one entry per workspace it wrote, and none at all when it + // wrote nothing. + if (((await resourceEditor?.save()) ?? []).length === 0) return // Enablement is keyed by path, so a rename would leave the switch on the path // that no longer exists and the server itself off. if (editingPath && editingPath !== server.path) { diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 24cd364d4b..d9836e1182 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -38,8 +38,8 @@ // 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. + // banner would go on claiming unsaved changes. Remounting re-reads the saved + // schedule; the one time it must not is over an edit the save deliberately kept. let generation = $state(0) // Loads the schedule this tab holds, on the instance the `{#key}` below just @@ -70,12 +70,10 @@ useDrawer={false} showDraftBanner {onRemoved} - onUpdate={(saved: string | undefined, from: string, fromWs: string) => { - // Remount only while this tab is still on the schedule that was saved: - // pointed elsewhere since, it would be taken back through a load it never - // asked for. The report goes out either way — the tabs on that schedule - // elsewhere have to hear, and a draft-only save can land on a new path. - if (from === path) generation++ + onUpdate={(saved: string | undefined, from: string, fromWs: string, keptEdit: boolean) => { + if (from === path && !keptEdit) generation++ + // A draft-only schedule opens with its path editable (saving CREATEs), so + // the save can land somewhere other than where the tab is pointed. if (saved) onSavedTo?.(saved, from, fromWs) }} > diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 297e3ca6ba..5b81470a93 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -44,7 +44,7 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' - import { flushDraftDelete, settleDraftAfterWrite } from '$lib/userDraft.svelte' + import { draftValuesEqual, flushDraftDelete, settleDraftAfterWrite } from '$lib/userDraft.svelte' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -624,6 +624,14 @@ deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { + // What was sent is the deployed value now. Adopted here rather than by + // remounting: the form stays editable during the write, and a remount would + // re-read over an edit made then — which `settleDraftAfterWrite` keeps. + initialConfig = structuredClone(scheduleCfg) + // An edit made while the write was in flight is kept rather than settled away + // — and the host must not remount over it, which is the only thing that can + // tell it so. + const keptEdit = !draftValuesEqual(getScheduleCfg(), scheduleCfg) settleDraftAfterWrite( 'trigger_schedule', scheduleCfg, @@ -632,7 +640,7 @@ scheduleCfg.path, { workspace: previousWs ?? undefined } ) - onUpdate?.(scheduleCfg.path, previousPath, previousWs) + onUpdate?.(scheduleCfg.path, previousPath, previousWs, keptEdit) drawer?.closeDrawer() } deploymentLoading = false @@ -748,7 +756,9 @@ return } sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) - onUpdate?.(path, path, ws) + // Deployed state moved, so the baseline the banner compares against does too. + if (initialConfig) initialConfig.enabled = nEnabled + onUpdate?.(path, path, ws, false) } } From 9c20945ad4c667a6d69ab93a09c2de19b03033f7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 08:39:41 +0200 Subject: [PATCH 29/69] fix(schedules): report what a write actually left diverging, and adopt what it deployed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enable/disable request carries the enabled flag alone, but claimed to leave nothing behind — so the host remounted, and a summary or args edit still sitting on the autosave debounce went with it. It now reports the form's real divergence from the baseline it just moved. A save also left `edit` and `draftOnly` to the remount, which is exactly what a kept edit skips: a draft-only schedule created while the user typed stayed in draft-only mode, with its path still editable and its next save a create. They follow the write that deployed it instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../triggers/schedules/ScheduleEditorInner.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 5b81470a93..a553480f51 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -632,6 +632,11 @@ // — and the host must not remount over it, which is the only thing that can // tell it so. const keptEdit = !draftValuesEqual(getScheduleCfg(), scheduleCfg) + // The schedule is deployed now, whether it was before or not. Set here rather + // than left to the remount, which a kept edit skips: they decide whether the + // next save updates or creates, and whether a discard removes the item. + edit = true + draftOnly = false settleDraftAfterWrite( 'trigger_schedule', scheduleCfg, @@ -758,7 +763,9 @@ sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) // Deployed state moved, so the baseline the banner compares against does too. if (initialConfig) initialConfig.enabled = nEnabled - onUpdate?.(path, path, ws, false) + // This request carried the enabled flag alone: anything else the form has + // diverged into is an edit of the user's, which a remount would drop. + onUpdate?.(path, path, ws, !draftValuesEqual(getScheduleCfg(), initialConfig)) } } From ba654d594f98c1ec6d33c45ab3cadcab39f6cf83 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 09:00:01 +0200 Subject: [PATCH 30/69] fix(sessions): identify the reporting tab by its session too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab ids are unique within a session, but the seeded first tab is `session` in every one of them — so a same-path save made from one skipped the refresh in all the others, leaving those editors on the baseline the deploy replaced. The exclusion carries the session it came from. Also drops the acting-workspace explanation left above `written`, which reports every workspace the save wrote. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/ResourceEditor.svelte | 3 --- .../lib/components/sessions/PreviewTabHost.svelte | 15 ++++++++++++--- .../routes/(root)/(logged)/sessions/+page.svelte | 4 +++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 36c0ccc065..c3130fc624 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -349,9 +349,6 @@ ini: $state.snapshot(initialStates[ws]) as ResourceState, existed: !!existedInitially[ws] })) - // The path the ACTING workspace's write used. `WsSpecificVersions` can point - // the form at a linked workspace, and a rename made there is that workspace's - // alone — reporting it would move a host that is looking at this one. // Every workspace this save wrote, in order, each with the path it wrote there. // The workspaces go in sequence and a later one throwing aborts the rest, but // what an earlier one wrote is deployed — a caller told nothing about it would diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index e1887fefc6..893e85e798 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -65,13 +65,15 @@ onLoad: (frame: HTMLIFrameElement) => void /** A hosted entity editor wrote its item: saved it (`to`, the path it wrote * to) or removed it. The page reaches every tab on that item, across warm - * sessions — `fromTabId` being the one that reported, which has settled its - * own state already. */ + * sessions — `fromSessionId`/`fromTabId` being the one that reported, which has + * settled its own state already. Both: tab ids are unique within a session but + * the seeded first tab is `session` in all of them. */ onEntityWritten: (ev: { kind: EntityEditorKind path: string workspace: string to?: string + fromSessionId: string | undefined fromTabId: string }) => void } = $props() @@ -198,7 +200,13 @@ const reportRemoved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => - onEntityWritten({ kind, path: fromPath, workspace: fromWs, fromTabId: tab.id }) + onEntityWritten({ + kind, + path: fromPath, + workspace: fromWs, + fromSessionId: session?.id, + fromTabId: tab.id + }) : undefined ) const reportSaved = $derived( @@ -209,6 +217,7 @@ path: fromPath, workspace: fromWs, to: newPath, + fromSessionId: session?.id, fromTabId: tab.id }) : undefined diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a2027b62b1..e7f2d70986 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -620,6 +620,7 @@ workspace: string /** The path it wrote to; absent when the write removed the item. */ to?: string + fromSessionId: string | undefined fromTabId: string }) { const page = entityListPage(ev.kind)?.path @@ -638,7 +639,8 @@ // baseline the deploy has replaced and would offer to discard back to it. // The reporting one has settled its own, including an edit typed while the // save was in flight, which a remount would re-read from under. - else if (tab.id !== ev.fromTabId) tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) + else if (s.id !== ev.fromSessionId || tab.id !== ev.fromTabId) + tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) } } } From fcb25ce4167d8bd28c74d617a0e3c9aa06df6e1b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 09:21:21 +0200 Subject: [PATCH 31/69] fix(schedules): adopt the enabled state a create deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a schedule sends `enabled: true` whatever the form held, so a draft-only save left the form and its new baseline recording a state the server does not have — visible once a kept edit skips the remount that used to hide it. Also moves the settle comment in VariableEditor back onto its call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/VariableEditor.svelte | 8 ++++---- .../triggers/schedules/ScheduleEditorInner.svelte | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 163a37aabb..7b78bd2216 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -338,16 +338,16 @@ initialStates[ws] = s existedInitially[ws] = true } - // The just-saved state is the new deployed baseline; `settleDraftAfterWrite` - // resets the handle to it via `discard` (not `remove` — blanking the cell - // to `undefined` reads as dirty), and keeps an edit made mid-request. Each - // workspace settles against the path its own write used, not the reported one. if (ws === fromWs) actingCommitted = true // Per workspace, as each write lands. Each carries its own workspace and // the path it wrote there, so a linked workspace's rename moves the tabs // acting on it and no others — and a workspace written before a later one // threw is still reported, because it is deployed. onSaved?.(s.path, from, ws) + // The just-saved state is the new deployed baseline; this resets the handle + // to it via `discard` (not `remove` — blanking the cell to `undefined` reads + // as dirty) and keeps an edit made mid-request. Each workspace settles + // against the path its own write used, not the reported one. settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index a553480f51..25fa955259 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -621,9 +621,17 @@ // `labels` alias live state, so an edit made while the request is in flight // would read back as part of what was sent. const scheduleCfg = $state.snapshot(getScheduleCfg()) as Record + const wasCreate = !edit deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { + // A create deploys the schedule enabled whatever the form said (see + // saveScheduleFromCfg), so both the form and what counts as written adopt it + // — otherwise the baseline below records a state the server does not have. + if (wasCreate) { + enabled = true + scheduleCfg.enabled = true + } // What was sent is the deployed value now. Adopted here rather than by // remounting: the form stays editable during the write, and a remount would // re-read over an edit made then — which `settleDraftAfterWrite` keeps. From 069d66627d485d2dc37b356ca54869445ba15fa0 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 09:41:47 +0200 Subject: [PATCH 32/69] fix(schedules): let the create's enabled state reach the form only if nothing changed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopting it unconditionally overwrote whatever the field held by then, so a write landing during the request — the chat seeding the cell — was erased before anything could notice it had, and settled away as if it were the save's own. What was written still records `true`, since that is what the create sent; the form follows only while it still holds the value that went with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../triggers/schedules/ScheduleEditorInner.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 25fa955259..d396c5fcb7 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -626,11 +626,14 @@ const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { // A create deploys the schedule enabled whatever the form said (see - // saveScheduleFromCfg), so both the form and what counts as written adopt it - // — otherwise the baseline below records a state the server does not have. + // saveScheduleFromCfg), so what counts as written records that — otherwise + // the baseline below claims a state the server does not have. The form + // follows only while it still holds what was sent: changed during the + // request, it is a newer edit, and `keptEdit` below is what protects it. if (wasCreate) { - enabled = true + const sentEnabled = scheduleCfg.enabled scheduleCfg.enabled = true + if (enabled === sentEnabled) enabled = true } // What was sent is the deployed value now. Adopted here rather than by // remounting: the form stays editable during the write, and a remount would From bfc9db5dfdae7307fb18dc04b2c03217765f30cd Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 10:08:35 +0200 Subject: [PATCH 33/69] fix(drafts): settle what a schedule toggle queues, and void a removal marker any write outlives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling enable writes the form before awaiting the deploy, so the autosave queues a draft against the pre-toggle baseline. Advancing the baseline on success then made that draft equal to it — invisible to the banner, still a row on the server and a `*` on the list. It is settled now, keeping an unrelated edit made during the request the way a save does. A draft-only removal marker was only ever voided by a chat write, so one left unread — discarded outside a session, where nothing consumes it — could be spent by an ordinary discard on a path since recreated, sending live editors away from an item that exists. Every write that gives the item a draft again voids it, which is also what `persistGlobalDraft` was doing by hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../copilot/chat/global/userDraftAdapter.ts | 3 --- .../schedules/ScheduleEditorInner.svelte | 23 +++++++++++++++---- frontend/src/lib/userDraft.svelte.ts | 13 ++++++----- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 3b1eeab8e8..f2c3f02431 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -421,9 +421,6 @@ export async function persistGlobalDraft( const itemKind = itemKindFor(type, opts.triggerKind) if (!itemKind) throw new Error(`Unsupported draft type "${type}".`) const storagePath = resolveDraftStoragePath(workspace, itemKind, path) - // Writing the item back means it exists again, so any removal marker left from - // an earlier draft-only discard is void. - UserDraft.clearDraftOnlyDiscard(itemKind, storagePath, { workspace }) UserDraft.seed(itemKind, storagePath, value, { workspace }) await UserDraftDbSyncer.save({ workspace, diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index d396c5fcb7..938f14b78f 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -626,10 +626,10 @@ const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { // A create deploys the schedule enabled whatever the form said (see - // saveScheduleFromCfg), so what counts as written records that — otherwise - // the baseline below claims a state the server does not have. The form - // follows only while it still holds what was sent: changed during the - // request, it is a newer edit, and `keptEdit` below is what protects it. + // saveScheduleFromCfg), so what counts as written records that — the + // baseline would otherwise claim a state the server does not have. The form + // follows only while it still holds what was sent; changed since, it is an + // edit of its own. if (wasCreate) { const sentEnabled = scheduleCfg.enabled scheduleCfg.enabled = true @@ -776,7 +776,20 @@ if (initialConfig) initialConfig.enabled = nEnabled // This request carried the enabled flag alone: anything else the form has // diverged into is an edit of the user's, which a remount would drop. - onUpdate?.(path, path, ws, !draftValuesEqual(getScheduleCfg(), initialConfig)) + const keptEdit = !draftValuesEqual(getScheduleCfg(), initialConfig) + // Setting `enabled` above queued a draft against the pre-toggle baseline. + // It matches the new one, so nothing would ever show it again — but it is + // still a row on the server and a `*` on the list until it is settled away. + if (initialConfig) + settleDraftAfterWrite( + 'trigger_schedule', + $state.snapshot(initialConfig), + getScheduleCfg(), + path, + path, + { workspace: ws ?? undefined } + ) + onUpdate?.(path, path, ws, keptEdit) } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 44975c0865..d1a95fe86f 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -320,6 +320,10 @@ export const UserDraft = { save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) + // The item has a draft again, so it exists again: a marker still standing + // (nothing consumed it, because nothing was listening) describes an item this + // write replaced, and would send the next editor to touch it away. + draftOnlyDiscards.delete(mk) const entry = entries.get(mk) if (entry) { // The reactive effect in `acquireEntry` observes this write @@ -463,6 +467,7 @@ export const UserDraft = { seed(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) + draftOnlyDiscards.delete(mk) const entry = entries.get(mk) if (!entry) { noteMarker(seedMisses, mk) @@ -483,12 +488,6 @@ export const UserDraft = { noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, - /** Drop any removal marker for this cell — the item exists again, so a marker - * still standing (nothing consumed it, because nothing was listening) would be - * read by whatever touches it next and send its editor away. */ - clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { - draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path)) - }, /** Whether the last discard for this cell removed the item outright (see * {@link recordDraftOnlyDiscard}), clearing the record. */ @@ -953,6 +952,8 @@ function acquireEntry( // copy. `untrack` so reactive reads in the predicate (the editor's // post-deploy baseline) don't re-fire the mirror. const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false)) + // Same as `save`: a value persisted here means the item exists again. + if (val !== undefined && !atBaseline) draftOnlyDiscards.delete(mk) void UserDraftDbSyncer.save({ workspace, itemKind, From 35ca2da8c9c5a9c42e264fea35d403942175163c Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 10:24:56 +0200 Subject: [PATCH 34/69] docs(drafts): trim the create-enabled comment and close the gap a removed method left Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../triggers/schedules/ScheduleEditorInner.svelte | 7 +++---- frontend/src/lib/userDraft.svelte.ts | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 938f14b78f..3898b817d2 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -626,10 +626,9 @@ const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { // A create deploys the schedule enabled whatever the form said (see - // saveScheduleFromCfg), so what counts as written records that — the - // baseline would otherwise claim a state the server does not have. The form - // follows only while it still holds what was sent; changed since, it is an - // edit of its own. + // saveScheduleFromCfg), so what counts as written records that — the baseline + // would otherwise claim a state the server does not have. The form follows + // only while it still holds what was sent; changed since, it is its own edit. if (wasCreate) { const sentEnabled = scheduleCfg.enabled scheduleCfg.enabled = true diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index d1a95fe86f..f64d6986ae 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -488,7 +488,6 @@ export const UserDraft = { noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, - /** Whether the last discard for this cell removed the item outright (see * {@link recordDraftOnlyDiscard}), clearing the record. */ takeDraftOnlyDiscard( From 8b99650d4b77477e920700c130f4a8419575f134 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 10:51:21 +0200 Subject: [PATCH 35/69] fix(sessions): reload a list tab a hosted editor's write changed, and treat a restore as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan-out matched only tabs holding the item, so a bare list frame — which has no row in its location to match — was skipped, and a save, rename or removal made in a hosted editor left it showing the old rows and draft markers. It reloads, the way a chat mutation on that page already made it. Restoring a version replaces the deployed value as squarely as a save does, but only the reporting editor heard: the other tabs kept the pre-restore value and a baseline that would write it back over the restored one. It reports too, under the workspace the history was pointed at. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditorDrawer.svelte | 6 ++++-- .../components/sessions/ResourceEditorView.svelte | 1 + .../routes/(root)/(logged)/sessions/+page.svelte | 13 +++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 2a75022349..e5be96e1ce 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -30,7 +30,9 @@ }: { workspace?: string disableChatOffset?: boolean - onRestored?: () => void + /** A version was restored, in the workspace the history was pointed at — which + * `WsSpecificVersions` can make a linked one rather than the acting workspace. */ + onRestored?: (workspace: string) => 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 — and the workspace and path * it started on. For a caller showing state derived from the resource; @@ -328,7 +330,7 @@ editorGeneration++ // Its own callback rather than the `refresh` event: callers bind that to // reopening a picker (EditorBar), which a restore should not trigger. - onRestored?.() + onRestored?.(historyWorkspace) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index cb9b98976a..28275557cc 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -45,6 +45,7 @@ workspace={workspaceId} {onBack} {onRemoved} + onRestored={(ws) => path && onSavedTo?.(path, path, ws)} onSaved={(saved, from, fromWs) => { if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs) }} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index e7f2d70986..09e6c70fd0 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -630,8 +630,17 @@ if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue for (const tab of owner.tabs) { const loc = whereIs(tab) + if (stripBase(loc) !== page) continue + const key = tabKey(s.id, tab.id) const entity = parseEntityEditorRoute(loc) - if (!entity || entity.path !== ev.path || stripBase(loc) !== page) continue + // A bare list tab shows every row, so any write on its page is its business + // — same as for a chat mutation, and its rows and `*` markers go stale + // otherwise. + if (!entity) { + if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + continue + } + if (entity.path !== ev.path) continue // The tab's own location, so the list it came from keeps its filters. if (!ev.to) owner.retargetTabTo(tab.id, entityListHref(loc)) else if (ev.to !== ev.path) owner.retargetTabTo(tab.id, entityEditorHref(loc, ev.to)) @@ -640,7 +649,7 @@ // The reporting one has settled its own, including an edit typed while the // save was in flight, which a remount would re-read from under. else if (s.id !== ev.fromSessionId || tab.id !== ev.fromTabId) - tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) + tabHosts[key]?.reload({ entity: 'refresh' }) } } } From d27eec52abd88cf3dfc159c3f836f382aef6a185 Mon Sep 17 00:00:00 2001 From: "windmill-internal-app[bot]" Date: Sun, 6 Sep 2026 11:43:38 +0000 Subject: [PATCH 36/69] chore: update ee-repo-ref to d33ea730c550cdbc7d050aeb6d40dcef3d134e07 This commit updates the EE repository reference after PR #782 was merged in windmill-ee-private. Previous ee-repo-ref: 313c572c9dcbcaafd8a1594df4054f9dd26f395c New ee-repo-ref: d33ea730c550cdbc7d050aeb6d40dcef3d134e07 Automated by sync-ee-ref workflow. --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 048726821c..0ebbb1b5ea 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -313c572c9dcbcaafd8a1594df4054f9dd26f395c +d33ea730c550cdbc7d050aeb6d40dcef3d134e07 From 08386433aa5f69fc0bcfb4d32126a62ad561ef9f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 13:50:54 +0200 Subject: [PATCH 37/69] fix(sessions): send a write's settled draft before the list frame re-reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle that follows a write parks its draft delete on the autosave debounce, so the list frame reloaded straight after read the row's `*` and kept it: its markers come from a store inside the iframe, and nothing reloads it a second time when the delete lands. The fan-out sends what is parked for the key first. That needed the variable save to report after settling rather than before — reporting first meant there was nothing parked yet to send, and the flush passed over a cell about to queue a delete. Every editor now resolves its cell before saying so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/VariableEditor.svelte | 11 ++++++----- frontend/src/lib/userDraft.svelte.ts | 16 +++++++++++++++- .../routes/(root)/(logged)/sessions/+page.svelte | 9 +++++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 7b78bd2216..c3e0c422d5 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -339,11 +339,6 @@ existedInitially[ws] = true } if (ws === fromWs) actingCommitted = true - // Per workspace, as each write lands. Each carries its own workspace and - // the path it wrote there, so a linked workspace's rename moves the tabs - // acting on it and no others — and a workspace written before a later one - // threw is still reported, because it is deployed. - onSaved?.(s.path, from, ws) // The just-saved state is the new deployed baseline; this resets the handle // to it via `discard` (not `remove` — blanking the cell to `undefined` reads // as dirty) and keeps an edit made mid-request. Each workspace settles @@ -351,6 +346,12 @@ settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) + // Reported after settling, so what hears about the write sees a cell that + // has already been resolved. Per workspace, each carrying its own and the + // path it wrote there, so a linked workspace's rename moves the tabs acting + // on it and no others — and a workspace written before a later one threw is + // still reported, because it is deployed. + onSaved?.(s.path, from, ws) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index f64d6986ae..8e7585dfb7 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -290,6 +290,20 @@ export function settleDraftAfterWrite( } } +/** + * Send whatever this cell has parked on the autosave debounce, now. For a reader + * that cannot be corrected once it has read — a list page inside an iframe, whose + * draft markers come from its own store — and would otherwise render a row the + * debounce has not caught up with. + */ +export async function flushDraftWrites( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions +): Promise { + await UserDraftDbSyncer.flush({ workspace: resolveWorkspace(opts), itemKind, path }) +} + /** * Wait for a queued draft delete to land, reporting whether the draft is gone. A * caller acts on this — a host leaves an editor whose item the discard removed — @@ -304,7 +318,7 @@ export async function flushDraftDelete( opts?: UserDraftOptions ): Promise { const query = { workspace: resolveWorkspace(opts), itemKind, path } - await UserDraftDbSyncer.flush(query) + await flushDraftWrites(itemKind, path, opts) return ( UserDraftDbSyncer.lastLandedWasDelete(query) && UserDraftDbSyncer.getState(query).state === 'none' diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 09e6c70fd0..3b55a02f9c 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -90,7 +90,7 @@ type WorkspaceItemKind } from '$lib/components/workspacePicker' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, flushDraftWrites } from '$lib/userDraft.svelte' const globalEnabled = isGlobalAiEnabled() @@ -614,7 +614,7 @@ // A hosted entity editor wrote its item. Every tab on it hears, across warm // sessions: they share the one draft cell and the one server row, so one left // behind edits a path that is gone or discards over a value already deployed. - function entityWritten(ev: { + async function entityWritten(ev: { kind: EntityEditorKind path: string workspace: string @@ -625,6 +625,11 @@ }) { const page = entityListPage(ev.kind)?.path if (!page) return + // The settle that follows a write parks its draft delete on the autosave + // debounce, and a list frame reads its `*` markers from a store of its own + // — read before that lands, it keeps the row's marker until something else + // reloads it, and nothing does. + await flushDraftWrites(ev.kind, ev.path, { workspace: ev.workspace }) for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue From 5b99cce1751ca1b14d8d19663f00a1beffecdf08 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 14:27:08 +0200 Subject: [PATCH 38/69] fix(drafts): finish settling a write before anything reads it, and follow late landings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timing the fan-out against the settle was the wrong direction. The settle now sends its own delete and resolves when it has gone out, so the editors report — and the schedule host remounts — on a cell that is already resolved: a stalled delete used to let that remount load the draft the save had just replaced, and restore it over the deployed value. A write can still land after all of that, when an edit made during the save is kept. A list frame reads its rows from a store of its own and nothing reloads it again, so it now reloads on the write landing rather than on our guess about when it will. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 4 +- .../src/lib/components/VariableEditor.svelte | 2 +- .../schedules/ScheduleEditorInner.svelte | 6 ++- frontend/src/lib/userDraft.svelte.ts | 8 +++- .../(root)/(logged)/sessions/+page.svelte | 47 ++++++++++++++++--- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index c3130fc624..38098a7e68 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -392,7 +392,9 @@ written.push({ ws, path: s.path }) // `s.path`, not the reported one: each workspace settles against the path // its own write used. - settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws }) + await settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { + workspace: ws + }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index c3e0c422d5..ff0e581e9e 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -343,7 +343,7 @@ // to it via `discard` (not `remove` — blanking the cell to `undefined` reads // as dirty) and keeps an edit made mid-request. Each workspace settles // against the path its own write used, not the reported one. - settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { + await settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) // Reported after settling, so what hears about the write sees a cell that diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 3898b817d2..685e739773 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -647,7 +647,9 @@ // next save updates or creates, and whether a discard removes the item. edit = true draftOnly = false - settleDraftAfterWrite( + // Awaited before the host hears: it remounts on the report, and a remount + // that overtook this would load the draft this is removing. + await settleDraftAfterWrite( 'trigger_schedule', scheduleCfg, getScheduleCfg(), @@ -780,7 +782,7 @@ // It matches the new one, so nothing would ever show it again — but it is // still a row on the server and a `*` on the list until it is settled away. if (initialConfig) - settleDraftAfterWrite( + await settleDraftAfterWrite( 'trigger_schedule', $state.snapshot(initialConfig), getScheduleCfg(), diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 8e7585dfb7..1f4f0da9df 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -277,16 +277,20 @@ export function draftValuesEqual(a: unknown, b: unknown): boolean { * only its own default), so the cell is reset rather than left orphaned under a * path the item no longer occupies. */ -export function settleDraftAfterWrite( +export async function settleDraftAfterWrite( itemKind: UserDraftItemKind, written: V, live: V | undefined, fromPath: string, savedPath: string, opts?: UserDraftOptions -): void { +): Promise { if (savedPath !== fromPath || draftValuesEqual(live, written)) { UserDraft.discard(itemKind, fromPath, written, opts) + // Sent before this resolves, not left on the keystroke debounce: callers + // report the write and remount on it, and both read a cell this has to have + // finished resolving — including through a frame with a draft store of its own. + await flushDraftWrites(itemKind, fromPath, opts) } } diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 3b55a02f9c..f92c8373e4 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -90,7 +90,8 @@ type WorkspaceItemKind } from '$lib/components/workspacePicker' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' - import { UserDraft, flushDraftWrites } from '$lib/userDraft.svelte' + import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' const globalEnabled = isGlobalAiEnabled() @@ -614,7 +615,7 @@ // A hosted entity editor wrote its item. Every tab on it hears, across warm // sessions: they share the one draft cell and the one server row, so one left // behind edits a path that is gone or discards over a value already deployed. - async function entityWritten(ev: { + function entityWritten(ev: { kind: EntityEditorKind path: string workspace: string @@ -625,11 +626,6 @@ }) { const page = entityListPage(ev.kind)?.path if (!page) return - // The settle that follows a write parks its draft delete on the autosave - // debounce, and a list frame reads its `*` markers from a store of its own - // — read before that lands, it keeps the row's marker until something else - // reloads it, and nothing does. - await flushDraftWrites(ev.kind, ev.path, { workspace: ev.workspace }) for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue @@ -665,6 +661,43 @@ pendingMutations = [] reloadTabs(pages, mutations) } + // A list frame reads its rows and `*` markers from a store inside the iframe, so + // nothing we do afterwards corrects what it already read. Rather than time our + // reloads against writes, reload when one lands: a draft written after a save — + // an edit made while it was in flight — reaches the list this way too. + let draftLandedHandle: ReturnType | undefined + let draftLandedPages = new Set() + $effect(() => { + const stop = UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => { + const page = entityListPage(itemKind as EntityEditorKind)?.path + if (!page) return + draftLandedPages.add(`${workspace}\u0000${page}`) + clearTimeout(draftLandedHandle) + // Coalesced: a deploy lands several writes for one item back to back. + draftLandedHandle = setTimeout(() => { + const pages = draftLandedPages + draftLandedPages = new Set() + for (const s of warmSessions) { + const ws = getEffectiveWorkspaceId(s) + const owner = getRuntime(s.id)?.previewTabs + if (!ws || !owner) continue + for (const tab of owner.tabs) { + const loc = whereIs(tab) + if (parseEntityEditorRoute(loc)) continue + if (!pages.has(`${ws}\u0000${stripBase(loc)}`)) continue + const key = tabKey(s.id, tab.id) + if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + } + } + }, 300) + }) + return () => { + clearTimeout(draftLandedHandle) + draftLandedPages = new Set() + stop() + } + }) + $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. setToolCompletionListener((name, args, workspace) => { From eb802612581d578f523ce0a3074cde0d08cc2e07 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 14:57:41 +0200 Subject: [PATCH 39/69] fix(editors): keep a save out of the toggle's field, and report only once every workspace has settled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An update sends no `enabled` — the toggle deploys that field by itself — so a save adopting its click-time snapshot wholesale rewound a toggle that had landed meanwhile: the form called the deployed state unsaved, and Discard put the old one back. The baseline takes the field from the toggle, not from the snapshot. A variable save reported each workspace as it landed, and the first report retargets the host, whose keying unmounts the editor at the next workspace's await — releasing the handles the workspaces after it settle against, so their drafts survived a successful save. Reports wait for the loop to finish. The list frames now share one debounced reload queue with the chat path, rather than reloading once per reason for one deploy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/VariableEditor.svelte | 17 +++++--- .../schedules/ScheduleEditorInner.svelte | 4 ++ frontend/src/lib/userDraft.svelte.ts | 7 ++- .../(root)/(logged)/sessions/+page.svelte | 43 ++++++------------- 4 files changed, 31 insertions(+), 40 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index ff0e581e9e..f55c5cad5a 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -299,6 +299,10 @@ // linked workspace, and a rename made there is that workspace's alone. const actingPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from let actingCommitted = false + // Every workspace written, with the path it wrote there. Reported once the loop + // is done: per workspace, each carrying its own, so a linked workspace's rename + // moves the tabs acting on it and no others. + const written: { ws: string; path: string }[] = [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -346,12 +350,10 @@ await settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) - // Reported after settling, so what hears about the write sees a cell that - // has already been resolved. Per workspace, each carrying its own and the - // path it wrote there, so a linked workspace's rename moves the tabs acting - // on it and no others — and a workspace written before a later one threw is - // still reported, because it is deployed. - onSaved?.(s.path, from, ws) + // Collected, not reported yet: a report retargets the host, whose `{#key + // path}` would unmount this editor at the next workspace's await and leave + // that one's handle released — its draft then settles against nothing. + written.push({ ws, path: s.path }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -360,6 +362,7 @@ edit ? `Updated variable in ${payloads.length} workspace(s)` : `Created variable` ) dispatch('create') + for (const w of written) onSaved?.(w.path, from, w.ws) // Only while this editor is still the one that was saved: re-pointed, // `editPath` is the variable it moved to. if (editPath === from) { @@ -370,6 +373,8 @@ } } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) + // Whatever committed before the throw is deployed, so it is still reported. + for (const w of written) onSaved?.(w.path, from, w.ws) // Reopened, so the handles come from the server: re-keying them would bring // back the values this drawer opened on, which describe neither what the // committed workspace now has deployed nor the draft the failed one still diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 685e739773..33e1326b0a 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -638,6 +638,10 @@ // remounting: the form stays editable during the write, and a remount would // re-read over an edit made then — which `settleDraftAfterWrite` keeps. initialConfig = structuredClone(scheduleCfg) + // An update's payload carries no `enabled` (see saveScheduleFromCfg): the + // toggle deploys that field on its own, so the deployed value is whatever it + // last set, not what this save's click-time snapshot happens to hold. + if (!wasCreate) initialConfig.enabled = enabled // An edit made while the write was in flight is kept rather than settled away // — and the host must not remount over it, which is the only thing that can // tell it so. diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 1f4f0da9df..2706a29f32 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -295,10 +295,9 @@ export async function settleDraftAfterWrite( } /** - * Send whatever this cell has parked on the autosave debounce, now. For a reader - * that cannot be corrected once it has read — a list page inside an iframe, whose - * draft markers come from its own store — and would otherwise render a row the - * debounce has not caught up with. + * Send whatever this cell has parked on the autosave debounce, now. For a caller + * that has to know the cell is resolved before it acts — settling after a write, + * before anything reports or remounts on it. */ export async function flushDraftWrites( itemKind: UserDraftItemKind, diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index f92c8373e4..a20286b19b 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -636,9 +636,10 @@ const entity = parseEntityEditorRoute(loc) // A bare list tab shows every row, so any write on its page is its business // — same as for a chat mutation, and its rows and `*` markers go stale - // otherwise. + // otherwise. Queued rather than reloaded here, so one deploy's writes and + // this report collapse into a single reload. if (!entity) { - if (mountedTabKeys.has(key)) tabHosts[key]?.reload() + queuePageReload(page) continue } if (entity.path !== ev.path) continue @@ -654,6 +655,14 @@ } } } + // One queue for every reason a previewed list page goes stale — a chat tool, a + // hosted editor's write, a draft landing afterwards — so the same frame is not + // reloaded several times for one deploy. + function queuePageReload(page: string) { + pendingPages.add(page) + clearTimeout(reloadHandle) + reloadHandle = setTimeout(flushReload, 500) + } function flushReload() { const pages = pendingPages const mutations = pendingMutations @@ -665,37 +674,11 @@ // nothing we do afterwards corrects what it already read. Rather than time our // reloads against writes, reload when one lands: a draft written after a save — // an edit made while it was in flight — reaches the list this way too. - let draftLandedHandle: ReturnType | undefined - let draftLandedPages = new Set() $effect(() => { - const stop = UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => { + return UserDraftDbSyncer.onAnySaved(({ itemKind }) => { const page = entityListPage(itemKind as EntityEditorKind)?.path - if (!page) return - draftLandedPages.add(`${workspace}\u0000${page}`) - clearTimeout(draftLandedHandle) - // Coalesced: a deploy lands several writes for one item back to back. - draftLandedHandle = setTimeout(() => { - const pages = draftLandedPages - draftLandedPages = new Set() - for (const s of warmSessions) { - const ws = getEffectiveWorkspaceId(s) - const owner = getRuntime(s.id)?.previewTabs - if (!ws || !owner) continue - for (const tab of owner.tabs) { - const loc = whereIs(tab) - if (parseEntityEditorRoute(loc)) continue - if (!pages.has(`${ws}\u0000${stripBase(loc)}`)) continue - const key = tabKey(s.id, tab.id) - if (mountedTabKeys.has(key)) tabHosts[key]?.reload() - } - } - }, 300) + if (page) queuePageReload(page) }) - return () => { - clearTimeout(draftLandedHandle) - draftLandedPages = new Set() - stop() - } }) $effect(() => { From 45c53a37daea1c398bd47ddfbdcb44e1979b7c40 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 15:22:19 +0200 Subject: [PATCH 40/69] fix(schedules): baseline the enabled state the server accepted, not the one the toggle is claiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle sets `enabled` before its request and puts it back if that fails, so the form's value is a claim until it lands — and a save adopting it wrote an uncommitted toggle into its baseline, where cancelling the request left it: a false dirty state, and a Discard offering a value the server never took. The committed value is tracked and both the baseline and what counts as written take it, so the settle compares like for like and drops its draft instead of leaving a row behind. The reload queue keys by workspace as well as page: a draft landing in one says nothing about the same page in another, and reloading it there costs that frame its scroll. A restore reports the path it restored rather than leaving the caller to read a live one its own await has outlived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 10 +++++--- .../sessions/ResourceEditorView.svelte | 2 +- .../schedules/ScheduleEditorInner.svelte | 16 +++++++++--- .../(root)/(logged)/sessions/+page.svelte | 25 +++++++++++++------ 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index e5be96e1ce..639ec91b2b 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -30,9 +30,11 @@ }: { workspace?: string disableChatOffset?: boolean - /** A version was restored, in the workspace the history was pointed at — which - * `WsSpecificVersions` can make a linked one rather than the acting workspace. */ - onRestored?: (workspace: string) => void + /** A version was restored, at this path and in the workspace the history was + * pointed at — which `WsSpecificVersions` can make a linked one rather than the + * acting workspace. Both reported rather than left for the caller to read off + * its own live state, which the restore's await has already outlived. */ + onRestored?: (workspace: string, path: string) => 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 — and the workspace and path * it started on. For a caller showing state derived from the resource; @@ -330,7 +332,7 @@ editorGeneration++ // Its own callback rather than the `refresh` event: callers bind that to // reopening a picker (EditorBar), which a restore should not trigger. - onRestored?.(historyWorkspace) + if (path) onRestored?.(historyWorkspace, path) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 28275557cc..11eff09e80 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -45,7 +45,7 @@ workspace={workspaceId} {onBack} {onRemoved} - onRestored={(ws) => path && onSavedTo?.(path, path, ws)} + onRestored={(ws, restored) => onSavedTo?.(restored, restored, ws)} onSaved={(saved, from, fromWs) => { if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs) }} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 33e1326b0a..de78ebb6d9 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -131,6 +131,10 @@ let initNewPath = $state(false) let path: string = $state('') let enabled: boolean = $state(false) + // What the server has accepted for `enabled`. The toggle sets `enabled` before + // its request and puts it back if that fails, so the form's value is a claim + // until then — and a save must not adopt a claim as its deployed baseline. + let deployedEnabled = false let pathError = $state('') let summary = $state('') let labels: string[] | undefined = $state(undefined) @@ -545,6 +549,7 @@ initialCronVersion = cronVersion isLatestCron = cronVersion == 'v2' enabled = cfg.enabled + deployedEnabled = cfg.enabled schedule = cfg.schedule initialSchedule = schedule timezone = cfg.timezone @@ -632,16 +637,18 @@ if (wasCreate) { const sentEnabled = scheduleCfg.enabled scheduleCfg.enabled = true + deployedEnabled = true if (enabled === sentEnabled) enabled = true + } else { + // An update's payload carries no `enabled` (see saveScheduleFromCfg), so + // this write did not move it: what counts as written keeps the value the + // server has accepted, and the baseline below with it. + scheduleCfg.enabled = deployedEnabled } // What was sent is the deployed value now. Adopted here rather than by // remounting: the form stays editable during the write, and a remount would // re-read over an edit made then — which `settleDraftAfterWrite` keeps. initialConfig = structuredClone(scheduleCfg) - // An update's payload carries no `enabled` (see saveScheduleFromCfg): the - // toggle deploys that field on its own, so the deployed value is whatever it - // last set, not what this save's click-time snapshot happens to hold. - if (!wasCreate) initialConfig.enabled = enabled // An edit made while the write was in flight is kept rather than settled away // — and the host must not remount over it, which is the only thing that can // tell it so. @@ -778,6 +785,7 @@ } sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) // Deployed state moved, so the baseline the banner compares against does too. + deployedEnabled = nEnabled if (initialConfig) initialConfig.enabled = nEnabled // This request carried the enabled flag alone: anything else the form has // diverged into is an edit of the user's, which a remount would drop. diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a20286b19b..56ed27870b 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -588,7 +588,13 @@ const owner = getRuntime(s.id)?.previewTabs if (!owner) continue const workspace = getEffectiveWorkspaceId(s) - for (const tab of tabsToReload(owner.tabs, pages)) { + // The queue names the workspace each page was touched in; this session only + // cares about the ones touched in its own. + const prefix = `${workspace}\u0000` + const mine = new Set( + [...pages].filter((p) => p.startsWith(prefix)).map((p) => p.slice(prefix.length)) + ) + for (const tab of tabsToReload(owner.tabs, mine)) { const key = tabKey(s.id, tab.id) // A list tab shows every row, so any mutation on its page is its // business. A hosted entity tab shows one item, and is told apart here. @@ -639,7 +645,7 @@ // otherwise. Queued rather than reloaded here, so one deploy's writes and // this report collapse into a single reload. if (!entity) { - queuePageReload(page) + queuePageReload(ev.workspace, page) continue } if (entity.path !== ev.path) continue @@ -657,9 +663,12 @@ } // One queue for every reason a previewed list page goes stale — a chat tool, a // hosted editor's write, a draft landing afterwards — so the same frame is not - // reloaded several times for one deploy. - function queuePageReload(page: string) { - pendingPages.add(page) + // reloaded several times for one deploy. Keyed by workspace too: a write in one + // says nothing about the same page in another, and reloading it there costs the + // frame its scroll and everything else it holds. + function queuePageReload(workspace: string | undefined, page: string) { + if (!workspace) return + pendingPages.add(`${workspace}\u0000${page}`) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) } @@ -675,9 +684,9 @@ // reloads against writes, reload when one lands: a draft written after a save — // an edit made while it was in flight — reaches the list this way too. $effect(() => { - return UserDraftDbSyncer.onAnySaved(({ itemKind }) => { + return UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => { const page = entityListPage(itemKind as EntityEditorKind)?.path - if (page) queuePageReload(page) + if (page) queuePageReload(workspace, page) }) }) @@ -686,7 +695,7 @@ setToolCompletionListener((name, args, workspace) => { const { pages, entity, path } = toolReloadEffect(name, args) if (pages.length === 0) return - for (const p of pages) pendingPages.add(p) + for (const p of pages) pendingPages.add(`${workspace}\u0000${p}`) // A write reaches a hosted editor through the draft cell it holds, but an // editor still loading holds none yet and the seed no-opped past it. The // miss is recorded when it happens — asking now would be too late, since From 2583569150196936a5ff238d13dc9eda7323db57 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 15:47:24 +0200 Subject: [PATCH 41/69] fix(schedules): take the deployed enabled state from the deployed config alone `loadScheduleCfg` applies the draft overlay as well as the deployed config, so recording the accepted `enabled` there let a draft's own value pass for one the server had taken: saving then settled the form back to it, and a schedule the user had switched on came out off, clean, with nothing said. It is read where the deployed config is the one on the form, and reset for a schedule that has none yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditorDrawer.svelte | 3 +-- .../triggers/schedules/ScheduleEditorInner.svelte | 8 ++++++-- .../routes/(root)/(logged)/sessions/+page.svelte | 14 +++++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 639ec91b2b..5718811ce7 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -32,8 +32,7 @@ disableChatOffset?: boolean /** A version was restored, at this path and in the workspace the history was * pointed at — which `WsSpecificVersions` can make a linked one rather than the - * acting workspace. Both reported rather than left for the caller to read off - * its own live state, which the restore's await has already outlived. */ + * acting workspace. */ onRestored?: (workspace: string, path: string) => 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 — and the workspace and path diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index de78ebb6d9..e38255439a 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -198,8 +198,11 @@ draftOnly = noDeployed if (!defaultCfg) { // Form holds DEPLOYED here; capture it as `initialConfig` so the - // dirty check / banner fires whenever a saved draft exists. + // dirty check / banner fires whenever a saved draft exists. This is also + // the only point where the deployed `enabled` is on the form — the draft + // overlay below can carry one of its own, which the server has not taken. initialConfig = structuredClone($state.snapshot(getScheduleCfg())) + deployedEnabled = enabled } if (draftOverlay) await loadScheduleCfg(draftOverlay) await draftSync.maybeRestore() @@ -353,6 +356,8 @@ runnable = undefined edit = false draftOnly = false + // Nothing deployed yet, so nothing accepted. + deployedEnabled = false // No deployed baseline for a brand-new schedule. The editor instance // is reused across open() calls, so clear any baseline left by a prior // openEdit — otherwise the "unsaved changes" banner / dirty check would @@ -549,7 +554,6 @@ initialCronVersion = cronVersion isLatestCron = cronVersion == 'v2' enabled = cfg.enabled - deployedEnabled = cfg.enabled schedule = cfg.schedule initialSchedule = schedule timezone = cfg.timezone diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 56ed27870b..7e08c93f19 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -572,8 +572,12 @@ const tabHosts: Record = {} let reloadHandle: ReturnType | undefined - // Base-stripped list-page paths (e.g. `/schedules`) a chat round touched since - // the last flush — see toolReloadEffect for how tools map to pages. + /** A list page in the workspace it was touched in — see `queuePageReload`. */ + function pageReloadKey(workspace: string, page: string): string { + return `${workspace}\u0000${page}` + } + // Pages touched since the last flush, by whatever touched them: a chat round + // (see toolReloadEffect), a hosted editor's write, a draft landing after one. let pendingPages = new Set() // The same round's mutations, kept whole rather than folded into one verdict: // a hosted entity editor is only affected by a mutation to its own item, in @@ -590,7 +594,7 @@ const workspace = getEffectiveWorkspaceId(s) // The queue names the workspace each page was touched in; this session only // cares about the ones touched in its own. - const prefix = `${workspace}\u0000` + const prefix = pageReloadKey(workspace ?? '', '') const mine = new Set( [...pages].filter((p) => p.startsWith(prefix)).map((p) => p.slice(prefix.length)) ) @@ -668,7 +672,7 @@ // frame its scroll and everything else it holds. function queuePageReload(workspace: string | undefined, page: string) { if (!workspace) return - pendingPages.add(`${workspace}\u0000${page}`) + pendingPages.add(pageReloadKey(workspace, page)) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) } @@ -695,7 +699,7 @@ setToolCompletionListener((name, args, workspace) => { const { pages, entity, path } = toolReloadEffect(name, args) if (pages.length === 0) return - for (const p of pages) pendingPages.add(`${workspace}\u0000${p}`) + for (const p of pages) pendingPages.add(pageReloadKey(workspace, p)) // A write reaches a hosted editor through the draft cell it holds, but an // editor still loading holds none yet and the seed no-opped past it. The // miss is recorded when it happens — asking now would be too late, since From e3c0f532ffe2e7c47ff1fd5832e2840f3e25f9e2 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 16:08:36 +0200 Subject: [PATCH 42/69] fix(schedules): record the deployed enabled state however the editor opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading sat inside the branch that captures a baseline, so a schedule opened with a config handed in — the trigger panel of a script or flow — never took one, and a save there settled against whatever the last open had left. Both ways in hold the deployed state on the form at that point, which is what the reading is about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../triggers/schedules/ScheduleEditorInner.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index e38255439a..e1814d67bb 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -198,12 +198,12 @@ draftOnly = noDeployed if (!defaultCfg) { // Form holds DEPLOYED here; capture it as `initialConfig` so the - // dirty check / banner fires whenever a saved draft exists. This is also - // the only point where the deployed `enabled` is on the form — the draft - // overlay below can carry one of its own, which the server has not taken. + // dirty check / banner fires whenever a saved draft exists. initialConfig = structuredClone($state.snapshot(getScheduleCfg())) - deployedEnabled = enabled } + // Whichever way this opened, the form holds the deployed state until the + // overlay below — which can carry an `enabled` the server has not taken. + deployedEnabled = enabled if (draftOverlay) await loadScheduleCfg(draftOverlay) await draftSync.maybeRestore() } finally { From e45a08c3872dd43a48bd2215938ac3077aab7b5b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 16:34:31 +0200 Subject: [PATCH 43/69] fix(schedules): let the banner see the baseline move under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasDraft` compares the form to the deployed baseline, and that baseline is a plain value on purpose — made reactive, the sync's effects track every field and a reset provokes a write that races its own delete. So a toggle that landed fixed the baseline where nothing was watching, and the drawer kept offering to discard changes it had already deployed until some other field moved. The baseline stays plain; a counter beside it marks when it moves, and the `deployed()` the banner reads depends on that. Bumped where the baseline is captured, adopted after a save, corrected after a toggle, and cleared. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../schedules/ScheduleEditorInner.svelte | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index e1814d67bb..1fb149d301 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -126,6 +126,11 @@ let drawerLoading = $state(true) let showLoading = $state(false) let initialConfig: Record | undefined = undefined + // Bumped whenever the baseline above moves. `initialConfig` itself stays a plain + // value — made reactive, the trigger sync's effects track every field of it and + // a reset provokes a write that races its own delete — but the banner has to + // re-read it, so `deployed()` depends on this instead. + let baselineNonce = $state(0) let extraPerms: Record = $state({}) let can_write = $state(true) let initNewPath = $state(false) @@ -172,7 +177,10 @@ drawerLoading: () => drawerLoading, getCfg: () => scheduleCfg, applyCfg: loadScheduleCfg, - deployed: () => initialConfig + deployed: () => { + baselineNonce + return initialConfig + } }) export async function openEdit( @@ -200,9 +208,12 @@ // Form holds DEPLOYED here; capture it as `initialConfig` so the // dirty check / banner fires whenever a saved draft exists. initialConfig = structuredClone($state.snapshot(getScheduleCfg())) + baselineNonce++ } - // Whichever way this opened, the form holds the deployed state until the - // overlay below — which can carry an `enabled` the server has not taken. + // The form's `enabled` before the draft overlay below, which can carry one the + // server has not taken. Opened on a loaded schedule that is the deployed + // value; opened on a config handed in — a trigger panel staging one — it is + // that panel's, which is as close to deployed as this path can see. deployedEnabled = enabled if (draftOverlay) await loadScheduleCfg(draftOverlay) await draftSync.maybeRestore() @@ -363,6 +374,7 @@ // openEdit — otherwise the "unsaved changes" banner / dirty check would // compare against a stale config. initialConfig = undefined + baselineNonce++ itemKind = (s?.is_flow ?? nis_flow) ? 'flow' : 'script' initialScriptPath = initial_script_path ?? '' fixedScriptPath = fixedScriptPath_ ?? '' @@ -653,6 +665,7 @@ // remounting: the form stays editable during the write, and a remount would // re-read over an edit made then — which `settleDraftAfterWrite` keeps. initialConfig = structuredClone(scheduleCfg) + baselineNonce++ // An edit made while the write was in flight is kept rather than settled away // — and the host must not remount over it, which is the only thing that can // tell it so. @@ -790,7 +803,10 @@ sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${path}`) // Deployed state moved, so the baseline the banner compares against does too. deployedEnabled = nEnabled - if (initialConfig) initialConfig.enabled = nEnabled + if (initialConfig) { + initialConfig.enabled = nEnabled + baselineNonce++ + } // This request carried the enabled flag alone: anything else the form has // diverged into is an edit of the user's, which a remount would drop. const keptEdit = !draftValuesEqual(getScheduleCfg(), initialConfig) From 689dea784dc58c4a12f02147d6ce97843b9ea3d1 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 17:01:14 +0200 Subject: [PATCH 44/69] fix(schedules): judge write access in the workspace being acted on, and an edit after the settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `can_write` came from the navigation workspace's membership while every read and write went to the session's, so a fork or linked workspace could disable a user who may edit there — or offer the controls to one who may not, until the backend said otherwise. It resolves that workspace's user, falling back to the current one until it lands and whenever they are the same. Whether a save kept an edit was decided before the settle, which awaits a request of its own with the form still editable. Anything typed across it counted as nothing, the host remounted, and the edit went with it. Read afterwards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../schedules/ScheduleEditorInner.svelte | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 1fb149d301..49b4491ff4 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -51,6 +51,9 @@ import { twMerge } from 'tailwind-merge' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { untrack } from 'svelte' + import { getUserExt } from '$lib/user' + import type { UserExt } from '$lib/stores' let { useDrawer = true, @@ -132,7 +135,17 @@ // re-read it, so `deployed()` depends on this instead. let baselineNonce = $state(0) let extraPerms: Record = $state({}) - let can_write = $state(true) + // The path and permissions of the loaded schedule, from which `can_write` is + // derived — the acting workspace can be a fork or a linked one, where this user's + // roles, groups and folders are not the ones `$userStore` describes. + let permsPath = $state('') + let permsExtra: Record | undefined = $state(undefined) + let actingUser: UserExt | undefined = $state(undefined) + const can_write = $derived( + permsExtra === undefined + ? true + : canWrite(permsPath, permsExtra, actingUser ?? $userStore) + ) let initNewPath = $state(false) let path: string = $state('') let enabled: boolean = $state(false) @@ -169,6 +182,24 @@ // session override is set, so the script is created in the session workspace. const wsParam = $derived(triggerWs?.() ? `&workspace=${encodeURIComponent(wsId!)}` : '') const scheduleCfg = $derived.by(getScheduleCfg) + // Resolved for the acting workspace; until it lands `can_write` falls back to the + // navigation user, which is right whenever the two are the same workspace. + $effect(() => { + const ws = wsId + untrack(() => { + actingUser = undefined + if (!ws) return + if (ws === $workspaceStore) { + actingUser = $userStore ?? undefined + return + } + void getUserExt(ws) + .then((u) => { + if (wsId === ws) actingUser = u + }) + .catch(() => {}) + }) + }) const draftSync = useTriggerDraftSync({ itemKind: 'trigger_schedule', @@ -626,7 +657,8 @@ dynamicSkipPath = cfg.dynamic_skip args = cfg.args ?? {} extraPerms = cfg.extra_perms ?? {} - can_write = canWrite(cfg.path, cfg.extra_perms, $userStore) + permsPath = cfg.path + permsExtra = cfg.extra_perms ?? {} tag = cfg.tag permissionedAs = cfg.permissioned_as selectedPermissionedAs = cfg.permissioned_as @@ -666,10 +698,7 @@ // re-read over an edit made then — which `settleDraftAfterWrite` keeps. initialConfig = structuredClone(scheduleCfg) baselineNonce++ - // An edit made while the write was in flight is kept rather than settled away - // — and the host must not remount over it, which is the only thing that can - // tell it so. - const keptEdit = !draftValuesEqual(getScheduleCfg(), scheduleCfg) + // The schedule is deployed now, whether it was before or not. Set here rather // than left to the remount, which a kept edit skips: they decide whether the // next save updates or creates, and whether a discard removes the item. @@ -685,7 +714,15 @@ scheduleCfg.path, { workspace: previousWs ?? undefined } ) - onUpdate?.(scheduleCfg.path, previousPath, previousWs, keptEdit) + // Read after the settle, not before: that awaits a request of its own with the + // form still editable, and an edit made in the meantime is one the host must + // not remount over — which this is the only thing that can tell it. + onUpdate?.( + scheduleCfg.path, + previousPath, + previousWs, + !draftValuesEqual(getScheduleCfg(), scheduleCfg) + ) drawer?.closeDrawer() } deploymentLoading = false @@ -807,9 +844,7 @@ initialConfig.enabled = nEnabled baselineNonce++ } - // This request carried the enabled flag alone: anything else the form has - // diverged into is an edit of the user's, which a remount would drop. - const keptEdit = !draftValuesEqual(getScheduleCfg(), initialConfig) + // Setting `enabled` above queued a draft against the pre-toggle baseline. // It matches the new one, so nothing would ever show it again — but it is // still a row on the server and a `*` on the list until it is settled away. @@ -822,7 +857,10 @@ path, { workspace: ws ?? undefined } ) - onUpdate?.(path, path, ws, keptEdit) + // This request carried the enabled flag alone, so anything else the form has + // diverged into is the user's — read after the settle, which awaits a request + // of its own with the form still editable. + onUpdate?.(path, path, ws, !draftValuesEqual(getScheduleCfg(), initialConfig)) } } From 0144162b017c98369c95d5024b8a9f917f983149 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 17:29:56 +0200 Subject: [PATCH 45/69] fix(drafts): keep a missed write's marker across the editor's own bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An editor adopts what it loaded as its cell's baseline the moment it mounts, and that load can predate a write that seeded the cell before it existed. Adopting it cleared the marker that write had left, so the session concluded the write had reached the form and skipped the re-read, leaving the editor on the older value. Bootstrap seeds stay out of the bookkeeping now — recording nothing and consuming nothing — while a real write still resolves a marker either way. `can_write` also stops snapshotting the current user when the acting workspace is the one being browsed, so it keeps following the layout's refresh. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../schedules/ScheduleEditorInner.svelte | 8 ++-- .../triggers/useTriggerDraftSync.svelte.ts | 3 +- .../lib/components/usePageDraftSync.svelte.ts | 2 +- frontend/src/lib/userDraft.svelte.ts | 20 +++++++-- frontend/src/lib/userDraftSeedMiss.test.ts | 45 +++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/userDraftSeedMiss.test.ts diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 49b4491ff4..e6beb46430 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -189,10 +189,10 @@ untrack(() => { actingUser = undefined if (!ws) return - if (ws === $workspaceStore) { - actingUser = $userStore ?? undefined - return - } + // Same workspace: leave it unset so `can_write` reads the live `$userStore`, + // which the layout refreshes periodically — a snapshot here would stop + // following that. + if (ws === $workspaceStore) return void getUserExt(ws) .then((u) => { if (wsId === ws) actingUser = u diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index ba09167d6d..6831b256c4 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -222,7 +222,8 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const cfg = opts.getCfg() if (ws && p && cfg != null) { UserDraft.seed(opts.itemKind, p, structuredClone($state.snapshot(cfg)) as Cfg, { - workspace: ws + workspace: ws, + baseline: true }) } }, diff --git a/frontend/src/lib/components/usePageDraftSync.svelte.ts b/frontend/src/lib/components/usePageDraftSync.svelte.ts index 8ca2b0beee..82195806b3 100644 --- a/frontend/src/lib/components/usePageDraftSync.svelte.ts +++ b/frontend/src/lib/components/usePageDraftSync.svelte.ts @@ -97,7 +97,7 @@ export function usePageDraftSync(opts: PageDraftSyncOptions): Pa const ws = opts.workspace() const p = opts.path() if (!ws || !p) return - UserDraft.seed(opts.itemKind, p, value, { workspace: ws }) + UserDraft.seed(opts.itemKind, p, value, { workspace: ws, baseline: true }) }, recordRemoteSync(draftSavedAt: string | undefined) { const ws = opts.workspace() diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 2706a29f32..501a7576f3 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -481,16 +481,28 @@ export const UserDraft = { * recorded for {@link takeSeedMiss}, since the value then reached the server * without reaching the editor that will show it. */ - seed(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { + seed( + itemKind: UserDraftItemKind, + path: string, + value: V, + opts?: UserDraftOptions & { + /** This seed carries what the editor just loaded, not a new value — so it + * says nothing about a write that missed the cell before the editor had it, + * and may well be older than one. Such a seed leaves the miss standing. */ + baseline?: boolean + } + ): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) - draftOnlyDiscards.delete(mk) + // A real write gives the item a draft again whether or not an editor is holding + // the cell, so it voids a removal recorded for it either way. + if (!opts?.baseline) draftOnlyDiscards.delete(mk) const entry = entries.get(mk) if (!entry) { - noteMarker(seedMisses, mk) + if (!opts?.baseline) noteMarker(seedMisses, mk) return } - seedMisses.delete(mk) + if (!opts?.baseline) seedMisses.delete(mk) entry.seedNextWrite = true entry.state.val = snapshotDraftValue(value) }, diff --git a/frontend/src/lib/userDraftSeedMiss.test.ts b/frontend/src/lib/userDraftSeedMiss.test.ts new file mode 100644 index 0000000000..6369a69ec5 --- /dev/null +++ b/frontend/src/lib/userDraftSeedMiss.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } })) +vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) +vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) +vi.mock('./userDraftDbSyncer.svelte', () => ({ + UserDraftDbSyncer: { save: vi.fn(), flush: vi.fn(async () => {}) } +})) + +import { UserDraft } from './userDraft.svelte' + +const OPTS = { workspace: 'ws' } +let n = 0 +const nextPath = () => `u/me/s${n++}` + +/** + * A chat write seeds the cell an editor is about to hold. Seeding before it does + * records the miss, which the session reads back to know the write never reached + * the form and the editor has to re-read it. + */ +describe('seed misses', () => { + it('records a miss when no editor holds the cell', () => { + const path = nextPath() + UserDraft.seed('trigger_schedule', path, { a: 1 }, OPTS) + expect(UserDraft.takeSeedMiss('trigger_schedule', path, OPTS)).toBe(true) + // Read once: the next action must not spend the same marker. + expect(UserDraft.takeSeedMiss('trigger_schedule', path, OPTS)).toBe(false) + }) + + // The editor adopts what it loaded as the cell's baseline once it mounts, and + // that load can predate the write that missed. Such a seed stays out of the + // bookkeeping entirely — it neither records a miss nor consumes one. + it('leaves a standing miss alone when the editor bootstraps its baseline', () => { + const path = nextPath() + UserDraft.seed('trigger_schedule', path, { a: 1 }, OPTS) + UserDraft.seed('trigger_schedule', path, { a: 0 }, { ...OPTS, baseline: true }) + expect(UserDraft.takeSeedMiss('trigger_schedule', path, OPTS)).toBe(true) + }) + + it('records nothing for a bootstrap seed of its own', () => { + const path = nextPath() + UserDraft.seed('trigger_schedule', path, { a: 0 }, { ...OPTS, baseline: true }) + expect(UserDraft.takeSeedMiss('trigger_schedule', path, OPTS)).toBe(false) + }) +}) From 29721d4034eb74d47777a22d734caba9bd0b03a4 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 17:54:42 +0200 Subject: [PATCH 46/69] fix(editors): follow the write made in the workspace whose version is on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WsSpecificVersions` can point the form at a linked workspace, and the form then followed the acting workspace's write instead — which for a rename made in the linked one meant following nothing, leaving the form on the item it shows with a draft handle still keyed to the path that item has left. It follows the shown workspace's write, falling back to the acting one, which is the same thing whenever they are the same workspace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditorDrawer.svelte | 7 ++++++- frontend/src/lib/components/VariableEditor.svelte | 12 +++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 5718811ce7..1f570b10fd 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -223,6 +223,10 @@ // workspace, while the write is in flight. const from = path const fromWs = effectiveWorkspace + // The workspace whose version is on screen, which `WsSpecificVersions` can + // make a linked one: a rename made there is that workspace's alone, but the + // form is showing it, so the form follows it. + const shownWs = selected // 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. @@ -232,7 +236,8 @@ // be showing a linked workspace's variant, whose rename is not this host's. const written = (await saving) ?? [] if (written.length === 0) return - const submitted = written.find((w) => w.ws === fromWs)?.path + const submitted = + written.find((w) => w.ws === shownWs)?.path ?? written.find((w) => w.ws === fromWs)?.path // Follow a rename: rendered inline there is no drawer to close, so the mounted // editor stays, and the key below remounts it on the path the item moved to. // The editor adopts its own new baseline, so a same-path save needs nothing diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index f55c5cad5a..234798a68c 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -295,9 +295,15 @@ ini: $state.snapshot(initialStates[ws]) as VariableState, existed: !!existedInitially[ws] })) - // What this editor itself follows: `WsSpecificVersions` can point the form at a - // linked workspace, and a rename made there is that workspace's alone. - const actingPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from + // What this editor itself follows: the workspace whose version is on screen, + // which `WsSpecificVersions` can make a linked one. A rename made there is that + // workspace's alone — the host is told per workspace and decides for itself — + // but the form is showing it, so the form goes with it. + const shownWs = selected + const actingPath = + payloads.find((pl) => pl.ws === shownWs)?.s.path ?? + payloads.find((pl) => pl.ws === fromWs)?.s.path ?? + from let actingCommitted = false // Every workspace written, with the path it wrote there. Reported once the loop // is done: per workspace, each carrying its own, so a linked workspace's rename From df06cc20fcaf0c9d58df080bc77dfe57b7edc5cc Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 18:14:25 +0200 Subject: [PATCH 47/69] fix(drafts): do not report a removal a queued edit has already undone, and track one workspace per save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat's discard reports its delete as landed once nothing failed or conflicted, but the form stays editable across it: an edit made then queues a write behind the delete and recreates the draft. The removal marker — which sends a hosted editor away from an item it says is gone — is only published when nothing is queued behind the delete, so it describes an item that is actually gone. The variable save's committed flag still followed the acting workspace after the path it pairs with began following the shown one, and its reopen let the editor reselect its default workspace. Both follow the workspace the form is showing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/VariableEditor.svelte | 14 +++++++++++--- .../src/lib/components/copilot/chat/global/core.ts | 11 +++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 234798a68c..391949ee00 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -304,7 +304,9 @@ payloads.find((pl) => pl.ws === shownWs)?.s.path ?? payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from - let actingCommitted = false + // Committed by the workspace the form is showing — the one `actingPath` follows, + // so the two cannot disagree about who this editor is tracking. + let shownCommitted = false // Every workspace written, with the path it wrote there. Reported once the loop // is done: per workspace, each carrying its own, so a linked workspace's rename // moves the tabs acting on it and no others. @@ -348,7 +350,7 @@ initialStates[ws] = s existedInitially[ws] = true } - if (ws === fromWs) actingCommitted = true + if (ws === (shownWs ?? fromWs)) shownCommitted = true // The just-saved state is the new deployed baseline; this resets the handle // to it via `discard` (not `remove` — blanking the cell to `undefined` reads // as dirty) and keeps an edit made mid-request. Each workspace settles @@ -385,7 +387,13 @@ // back the values this drawer opened on, which describe neither what the // committed workspace now has deployed nor the draft the failed one still // holds — that one stays at its own path, editable from its workspace. - if (actingCommitted && actingPath && editPath === from) editVariable(actingPath) + if (shownCommitted && actingPath && editPath === from) { + // Reopen on the workspace whose write this is following, not on the one the + // editor defaults to: `editVariable` selects `curWs`, which for a linked + // version would load a different workspace's item at this path. + editVariable(actingPath) + if (shownWs) selected = shownWs + } } } diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 18dbf9c077..99943c8d2b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6182,9 +6182,16 @@ async function discardLocalDraft( // Published only now: the delete above can throw, and the marker is read by the // tool-completion listener, which a throw never reaches — leaving it to be // consumed by some later action on this item, which would send its editor away - // while the item is still there. + // while the item is still there. Nor is it published when a write is queued + // behind the delete: the form stays editable across it, and an edit made then + // recreates the draft, so the item the marker would report as gone is not. if (removesItem && discardedKind) { - UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) + const settled = + UserDraftDbSyncer.getState({ workspace, itemKind: discardedKind, path: storagePath }) + .state === 'none' + if (settled) { + UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) + } } // The chat's touch on the item is undone — drop it from the mask so a From e1859f504daf349c85d93d0249e9b26311caa988 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 18:27:56 +0200 Subject: [PATCH 48/69] docs(drafts): trim the removal-marker invariant to four lines Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/copilot/chat/global/core.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 99943c8d2b..8589dc4945 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6179,12 +6179,10 @@ async function discardLocalDraft( await deleteGlobalDraft(workspace, type, path, triggerKind) - // Published only now: the delete above can throw, and the marker is read by the - // tool-completion listener, which a throw never reaches — leaving it to be - // consumed by some later action on this item, which would send its editor away - // while the item is still there. Nor is it published when a write is queued - // behind the delete: the form stays editable across it, and an edit made then - // recreates the draft, so the item the marker would report as gone is not. + // Published only now, and only if nothing is queued behind the delete: the marker + // sends a hosted editor away from an item it says is gone, so a throw above must + // not leave one standing for a later action to spend, and an edit made across the + // delete recreates the draft — leaving the item the marker would report as gone. if (removesItem && discardedKind) { const settled = UserDraftDbSyncer.getState({ workspace, itemKind: discardedKind, path: storagePath }) From 239e19e7c6bdeb39cee603831f43c47b933622bd Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 18:48:07 +0200 Subject: [PATCH 49/69] fix(drafts): see the settle through, so a rename leaves nothing at the path it left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle sent its delete and returned on the first flush, but the form stays editable across that request: an edit made then parks a write behind it, and once the callers re-key to the new path that write lands as a draft under a path the item no longer occupies, with no editor on it. The delete is re-sent while the key refuses to settle on it — bounded, since a form still being typed into can always add one more. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/userDraft.svelte.ts | 16 +++++++---- .../src/lib/userDraftSettleAfterWrite.test.ts | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 501a7576f3..17ae1d0d74 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -285,12 +285,18 @@ export async function settleDraftAfterWrite( savedPath: string, opts?: UserDraftOptions ): Promise { - if (savedPath !== fromPath || draftValuesEqual(live, written)) { + if (savedPath === fromPath && !draftValuesEqual(live, written)) return + // Sent before this resolves, not left on the keystroke debounce: callers report + // the write and remount on it, and both read a cell this has to have finished + // resolving — including through a frame with a draft store of its own. + // + // Re-sent while the key is not settled on that delete: the form stays editable + // across the request, and an edit made then parks a write behind it that would + // put a draft back — under a path the item has left, once the callers re-key. + // Bounded, because a form still being typed into can always add one more. + for (let attempt = 0; attempt < 3; attempt++) { UserDraft.discard(itemKind, fromPath, written, opts) - // Sent before this resolves, not left on the keystroke debounce: callers - // report the write and remount on it, and both read a cell this has to have - // finished resolving — including through a frame with a draft store of its own. - await flushDraftWrites(itemKind, fromPath, opts) + if (await flushDraftDelete(itemKind, fromPath, opts)) return } } diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts index 039629f8da..7c3407e492 100644 --- a/frontend/src/lib/userDraftSettleAfterWrite.test.ts +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' const discard = vi.fn() +// Whether the key ends up settled on the delete after a flush — false stands for a +// form that keeps queueing writes behind it. +let settles = true vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } })) vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } })) vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) +vi.mock('./userDraftDbSyncer.svelte', () => ({ + UserDraftDbSyncer: { + save: vi.fn(), + flush: vi.fn(async () => {}), + lastLandedWasDelete: vi.fn(() => settles), + getState: vi.fn(() => ({ state: 'none' })) + } +})) import { settleDraftAfterWrite, UserDraft } from './userDraft.svelte' beforeEach(() => { + settles = true discard.mockClear() vi.spyOn(UserDraft, 'discard').mockImplementation(discard as any) }) @@ -53,6 +65,21 @@ describe('settleDraftAfterWrite', () => { expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) }) + // The form stays editable across the delete's own request, so an edit made then + // parks a write that would put the draft back — under a path the item has left, + // once the callers re-key. The delete is re-sent while that is the case, and + // bounded, because a form being typed into can always add one more. + it('re-sends the delete while the key will not settle on it, and gives up bounded', async () => { + settles = false + await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) + expect(discard).toHaveBeenCalledTimes(3) + }) + + it('sends it once when the key settles', async () => { + await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) + expect(discard).toHaveBeenCalledTimes(1) + }) + it('compares through the draft normalization, so a nested edit is not missed', () => { const written = { path: 'u/me/a', args: { host: 'h' } } settleDraftAfterWrite( From e65929665c7326e3497ceb8fe6c7876f31e18a7e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 19:16:36 +0200 Subject: [PATCH 50/69] fix(drafts): stop a settled save from swallowing the next edit, and scope its retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settling a save that changed nothing hands the cell a value it already holds, and the mirror's no-op branch defused the seed guard but not the one a discard arms — so the guard stayed up and ate the user's next keystroke, which then reached neither the server nor the unload flush. It is defused in the same place and for the same reason as the other. Two more from the same helper: a released handle is no cell at all rather than a newer edit, so a save whose drawer has closed still cleans up after itself; and the delete is only re-sent for an item that moved, since on an unchanged path the write queued behind it is the user's and resending would revert it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/userDraft.svelte.ts | 23 +++++++++++++------ .../src/lib/userDraftSettleAfterWrite.test.ts | 16 +++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 17ae1d0d74..8c681592d7 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -285,16 +285,21 @@ export async function settleDraftAfterWrite( savedPath: string, opts?: UserDraftOptions ): Promise { - if (savedPath === fromPath && !draftValuesEqual(live, written)) return + // No cell at all is not a newer edit — the editor has been released, and its + // draft is precisely what nothing is watching any more. + const diverged = live !== undefined && !draftValuesEqual(live, written) + if (savedPath === fromPath && diverged) return // Sent before this resolves, not left on the keystroke debounce: callers report // the write and remount on it, and both read a cell this has to have finished // resolving — including through a frame with a draft store of its own. - // - // Re-sent while the key is not settled on that delete: the form stays editable - // across the request, and an edit made then parks a write behind it that would - // put a draft back — under a path the item has left, once the callers re-key. - // Bounded, because a form still being typed into can always add one more. - for (let attempt = 0; attempt < 3; attempt++) { + UserDraft.discard(itemKind, fromPath, written, opts) + if (await flushDraftDelete(itemKind, fromPath, opts)) return + // Only a moved item forces the point. The form stays editable across that + // request, so an edit made then parks a write behind it: under the path the + // item has left that write would strand a draft no editor is on, but under an + // unchanged path it is the user's, and is meant to stand and read dirty. + if (savedPath === fromPath) return + for (let attempt = 0; attempt < 2; attempt++) { UserDraft.discard(itemKind, fromPath, written, opts) if (await flushDraftDelete(itemKind, fromPath, opts)) return } @@ -958,6 +963,10 @@ function acquireEntry( // stays armed: an undefined-seeded cell's initial run lands // here, and page editors rely on it to swallow their load write.) if (entry.seedNextWrite) entry.seedNextWrite = false + // Same for a `discard`/`remove` whose fallback equals what the cell + // already holds — settling a save that changed nothing does exactly + // that — which otherwise leaves the guard armed for the next edit. + if (entry.skipNextSync) entry.skipNextSync = false return } lastSerialized = next diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts index 7c3407e492..cc58f25e79 100644 --- a/frontend/src/lib/userDraftSettleAfterWrite.test.ts +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -75,6 +75,22 @@ describe('settleDraftAfterWrite', () => { expect(discard).toHaveBeenCalledTimes(3) }) + // A released handle reads as no cell at all. That is not somebody's newer edit — + // it is a draft nothing is watching — so the cleanup still has to run. + it('cleans up when the editor has been released', async () => { + await settleDraftAfterWrite('variable', sent, undefined, 'u/me/a', 'u/me/a', OPTS) + expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) + }) + + // Retrying resets the cell to what was written, so it may only chase a path the + // item has left. On an unchanged path an edit made during the delete is the + // user's, and re-sending would revert it. + it('does not retry over an edit made on an unchanged path', async () => { + settles = false + await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/a', OPTS) + expect(discard).toHaveBeenCalledTimes(1) + }) + it('sends it once when the key settles', async () => { await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) expect(discard).toHaveBeenCalledTimes(1) From 3581a0e380bd925a17d5708d87bde489bc8b7220 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 19:49:10 +0200 Subject: [PATCH 51/69] fix(sessions): keep what a released editor was holding, and scope the ws selector to the drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An editor stays editable while its save is in flight, so a tab left in that window releases a cell that may carry an edit newer than the write — and a released cell reads as no cell at all, which the settle took for a draft nothing was watching and deleted. Keep what the last holder let go of, bounded like the other write markers, and let the settle read that when no handle is left. The linked-workspace selector follows the hand-off button: a session tab is keyed to one workspace, so re-pointing the editor at a linked one from inside a tab acted on an item the session is not about. A removal marker now also requires the delete to be what landed, as `flushDraftDelete` does: an upsert queued behind it displaces the delete, and waiting for the chain then reports idle on a draft that is back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 18 +++++--- .../src/lib/components/VariableEditor.svelte | 6 ++- .../components/copilot/chat/global/core.ts | 11 +++-- frontend/src/lib/userDraft.svelte.ts | 41 +++++++++++++++++-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 1f570b10fd..56f5c7aea4 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -206,12 +206,18 @@ > History - + + {#if useDrawer} + + {/if} {/if} diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 7624cbf756..d7794c2b0c 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -143,13 +143,12 @@ function noteMarker(set: Set, key: string): void { } } /** - * What a cell held when its last holder let go. An editor stays editable while - * its save is in flight, so a tab closed in that window releases a cell that may - * carry an edit newer than the write — and once released there is no handle left - * to read it from. Same cap and reason as the marker sets; taken by whoever asks - * (`settleDraftAfterWrite`), and dropped when the key is acquired again, since a - * live entry answers for itself. + * What a cell held when its last holder let go: an editor stays editable while its + * save is in flight, so a tab left in that window releases a cell carrying an edit + * newer than the write. Only for the kinds whose settle reads it back — elsewhere + * it would retain a whole flow or app draft, or a variable's decrypted secret. */ +const RELEASE_RECORDED_KINDS: readonly UserDraftItemKind[] = ['resource', 'variable'] const releasedValues = new Map() /** Read once: what it describes is settled by the caller that reads it. */ function takeReleasedValue( @@ -1060,7 +1059,9 @@ function releaseEntry(mk: string): void { // only here, once, at refcount 0. This is what lets multiple holders (warm // session previews + the nav editor) share the entry and drop in any order. if (entry.count <= 0) { - noteReleasedValue(mk, snapshotDraftValue(entry.state.val)) + if (RELEASE_RECORDED_KINDS.includes(entry.itemKind)) { + noteReleasedValue(mk, snapshotDraftValue(entry.state.val)) + } // The live entry was authoritative while mounted; once gone, drop any // cached write for this key so a later read falls back to the server // rather than a value the editor may have changed in the meantime. From d58ef8eb55d1e63af0885dc1d339969deb623804 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 21:24:13 +0200 Subject: [PATCH 53/69] fix(resources): keep ResourceEditor.save() boolean, and report writes through saveWritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResourceEditor` is an entry point of the packaged `windmill-components`, so its `save()` is a public contract: consumers outside this repo branch on the boolean it has always returned, and an array reads as success on every failure. The per-workspace results move to `saveWritten`, which the drawer takes. The released-values doc goes back on the map it describes, with the constant carrying its own — and the kinds it excludes stated as they are: a variable's cell is recorded, decrypted secret and all, since that is a kind whose settle reads the record back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 22 +++++++++++++++---- .../components/ResourceEditorDrawer.svelte | 4 ++-- .../copilot/chat/AssistantMcpSection.svelte | 2 +- frontend/src/lib/userDraft.svelte.ts | 9 +++++--- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 38098a7e68..553cfbc997 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -337,7 +337,22 @@ /** What the save deployed: one entry per workspace written, with the path it * wrote there. Empty when nothing landed — it toasts its own failure. */ - export async function save(): Promise<{ ws: string; path: string }[]> { + /** + * Whether the save completed, as this component's packaged API has always + * reported it: it is an entry point of `windmill-components`, so callers + * outside this repo branch on the boolean. A host that has to follow what each + * workspace wrote takes `saveWritten` instead. + */ + export async function save(): Promise { + return (await runSave()).ok + } + + /** Every workspace this save wrote, in order, each with the path it wrote there. */ + export async function saveWritten(): Promise<{ ws: string; path: string }[]> { + return (await runSave()).written + } + + async function runSave(): Promise<{ written: { ws: string; path: string }[]; ok: boolean }> { // Everything the writes send, read before the first await. The form stays // editable while they are in flight, so read later these would be whatever // the user has since typed — sent under an earlier workspace's path, and @@ -349,7 +364,6 @@ ini: $state.snapshot(initialStates[ws]) as ResourceState, existed: !!existedInitially[ws] })) - // Every workspace this save wrote, in order, each with the path it wrote there. // The workspaces go in sequence and a later one throwing aborts the rest, but // what an earlier one wrote is deployed — a caller told nothing about it would // stay pointed at a path the item has moved off. @@ -405,13 +419,13 @@ : `Saved resource` ) dispatch('refresh', written.find((w) => w.ws === effectiveWorkspace)?.path ?? from) - return written + return { written, ok: true } } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) // The workspaces that never got their write keep their drafts at their own // paths, listed and editable from there — moving one onto a rename it never // wrote would be worse than not showing it here. - return written + return { written, ok: false } } } diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 56f5c7aea4..f3079fb960 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -68,7 +68,7 @@ | { /** One entry per workspace written, with the path it wrote there; empty when * nothing landed. It toasts its own error. */ - save: () => Promise<{ ws: string; path: string }[]> + saveWritten: () => Promise<{ ws: string; path: string }[]> localDraftDeployed: () => unknown localDraftCurrent: () => unknown /** False when the discard removed the resource (it was draft-only). */ @@ -236,7 +236,7 @@ // 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 saving = resourceEditor?.save() + const saving = resourceEditor?.saveWritten() drawer?.closeDrawer() // What landed, per workspace, from the editor rather than the form: the form may // be showing a linked workspace's variant, whose rename is not this host's. diff --git a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte index 24b707756a..30d0445e2c 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte @@ -235,7 +235,7 @@ switch that decides whether this chat carries its tools. // that still exists off, and turn on a path that was never created. // `save` reports one entry per workspace it wrote, and none at all when it // wrote nothing. - if (((await resourceEditor?.save()) ?? []).length === 0) return + if (!(await resourceEditor?.save())) return // Enablement is keyed by path, so a rename would leave the switch on the path // that no longer exists and the server itself off. if (editingPath && editingPath !== server.path) { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index d7794c2b0c..e9db2f1696 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -142,13 +142,16 @@ function noteMarker(set: Set, key: string): void { set.delete(oldest) } } +// Only the kinds whose settle can read the record back. For the rest, recording one +// would retain a whole flow, script or app draft that nothing will ever ask about. +const RELEASE_RECORDED_KINDS: readonly UserDraftItemKind[] = ['resource', 'variable'] + /** * What a cell held when its last holder let go: an editor stays editable while its * save is in flight, so a tab left in that window releases a cell carrying an edit - * newer than the write. Only for the kinds whose settle reads it back — elsewhere - * it would retain a whole flow or app draft, or a variable's decrypted secret. + * newer than the write, and no handle is left to read it from. Same cap and reason + * as the marker sets above. */ -const RELEASE_RECORDED_KINDS: readonly UserDraftItemKind[] = ['resource', 'variable'] const releasedValues = new Map() /** Read once: what it describes is settled by the caller that reads it. */ function takeReleasedValue( From 81eb6cf115e1406d885949dd44f8316f08c37f1b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 21:35:16 +0200 Subject: [PATCH 54/69] docs(resources): describe save()'s boolean contract where it is read Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/ResourceEditor.svelte | 2 -- .../src/lib/components/copilot/chat/AssistantMcpSection.svelte | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 553cfbc997..56540a029f 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -335,8 +335,6 @@ current.path = npath } - /** What the save deployed: one entry per workspace written, with the path it - * wrote there. Empty when nothing landed — it toasts its own failure. */ /** * Whether the save completed, as this component's packaged API has always * reported it: it is an entry point of `windmill-components`, so callers diff --git a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte index 30d0445e2c..93bb5f24ca 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte @@ -233,8 +233,7 @@ switch that decides whether this chat carries its tools. // A failed save leaves the connection exactly as it was, so none of the // bookkeeping below may run: moving the enablement then would turn a server // that still exists off, and turn on a path that was never created. - // `save` reports one entry per workspace it wrote, and none at all when it - // wrote nothing. + // False whenever a write threw, a partial multi-workspace save included. if (!(await resourceEditor?.save())) return // Enablement is keyed by path, so a rename would leave the switch on the path // that no longer exists and the server itself off. From 724b48eedd5961d29318cef455676a4959fbd125 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 09:53:19 +0200 Subject: [PATCH 55/69] fix(drafts): settle nothing for a create, and make a rename's delete final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A create's form cell is the detached handle `useMany` gives an empty path, so the settle addressed its requests to `/drafts/update//`: three 404s on every resource, variable and schedule created from a blank form. It has no draft to settle — short-circuit. Following that create's path afterwards re-keyed the blank cell onto the item just made, and the mirror posted it as a draft: every new variable and resource landed with an empty draft over it, secret flag and all. Only a move is followed now. The rename retry loop existed because the form stays editable across the delete's request and each keystroke queued a write behind it. Suspending the old key for the length of the delete removes the race instead of racing it: one attempt is final, and a delete that still will not land is a conflict or a failed request, which no retry changes and which nothing said out loud before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 6 ++- .../src/lib/components/VariableEditor.svelte | 6 ++- frontend/src/lib/userDraft.svelte.ts | 38 +++++++++++++------ .../src/lib/userDraftSettleAfterWrite.test.ts | 31 ++++++++++----- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index f3079fb960..f2f888915a 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -249,8 +249,10 @@ // The editor adopts its own new baseline, so a same-path save needs nothing // here — remounting it would re-read over an edit made during the write. // Only while this is still the resource it saved: re-pointed mid-write, - // `path` and the mounted editor are another one's. - if (path === from && submitted) path = submitted + // `path` and the mounted editor are another one's. And only a move: pointing a + // create at the path it just made re-keys the blank form's cell onto it, and + // the mirror then posts that blank state as the new item's draft. + if (path && path === from && submitted) path = submitted // One report per workspace written, each carrying its own — a linked // workspace's rename moves the tabs acting on it and no others. Reported // even when this drawer has moved on: the write is a fact about the item. diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index a630ce3f28..20e11feeca 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -375,8 +375,10 @@ // `editPath` is the variable it moved to. if (editPath === from) { // A rename moved the item; the drawer host closes over it, but an inline one - // stays mounted, so follow the new path here. - if (actingPath && actingPath !== editPath) editPath = actingPath + // stays mounted, so follow the new path here. Only a move: pointing a create + // at the path it just made re-keys the blank form's cell onto it, and the + // mirror then posts that blank state as the new item's draft. + if (editPath && actingPath && actingPath !== editPath) editPath = actingPath drawer?.closeDrawer() } } catch (err) { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index e9db2f1696..401b770023 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -2,7 +2,7 @@ import { get } from 'svelte/store' import { onDestroy, untrack } from 'svelte' import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' -import { readFieldsRecursively } from './utils' +import { readFieldsRecursively, sendUserToast } from './utils' import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' import type { UserDraftItemKind } from './gen' @@ -317,6 +317,10 @@ export async function settleDraftAfterWrite( savedPath: string, opts?: UserDraftOptions ): Promise { + // A create has nothing to settle: the blank form's cell is the detached handle + // `useMany` hands an empty path, wired to no key, so every request made for one + // is addressed to `/drafts/update//` and 404s. + if (!fromPath) return // A released cell reads as no cell at all, and its draft is then precisely what // nothing is watching any more — except when the release itself is what took // the handle away mid-write, in which case what it held still speaks for the @@ -324,20 +328,30 @@ export async function settleDraftAfterWrite( const held = live !== undefined ? live : takeReleasedValue(itemKind, fromPath, opts) const diverged = held !== undefined && !draftValuesEqual(held, written) if (savedPath === fromPath && diverged) return - // Sent before this resolves, not left on the keystroke debounce: callers report - // the write and remount on it, and both read a cell this has to have finished - // resolving — including through a frame with a draft store of its own. - UserDraft.discard(itemKind, fromPath, written, opts) - if (await flushDraftDelete(itemKind, fromPath, opts)) return - // Only a moved item forces the point. The form stays editable across that - // request, so an edit made then parks a write behind it: under the path the - // item has left that write would strand a draft no editor is on, but under an - // unchanged path it is the user's, and is meant to stand and read dirty. - if (savedPath === fromPath) return - for (let attempt = 0; attempt < 2; attempt++) { + // The form stays editable across the delete's own request, so a keystroke made + // then queues a write behind it. Under an unchanged path that write is the + // user's and is meant to stand; under a path the item has left it would put the + // draft back where no editor is, so the old key stops accepting writes for the + // length of the delete — which is what makes one attempt final. The edit stays + // in the form, and could not have followed the rename in either case. + const moved = savedPath !== fromPath + if (moved) UserDraft.stopSync(itemKind, fromPath, opts) + try { + // Sent before this resolves, not left on the keystroke debounce: callers report + // the write and remount on it, and both read a cell this has to have finished + // resolving — including through a frame with a draft store of its own. UserDraft.discard(itemKind, fromPath, written, opts) if (await flushDraftDelete(itemKind, fromPath, opts)) return + } finally { + if (moved) UserDraft.restartSync(itemKind, fromPath, opts) } + if (!moved) return + // A rename whose delete could not land — a conflict, or a failed request; no + // number of retries changes either. What is left is a draft-only item at a path + // nothing is editing. Callers still follow the rename, since the item really is + // at `savedPath` and leaving them behind would strand the editor too, so this is + // the only thing that says the leftover is there to be discarded. + sendUserToast(`Saved, but the draft left at ${fromPath} could not be cleared`, true) } /** diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts index cc58f25e79..d2be6ad0dc 100644 --- a/frontend/src/lib/userDraftSettleAfterWrite.test.ts +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' const discard = vi.fn() +const { toast } = vi.hoisted(() => ({ toast: vi.fn() })) +vi.mock('./utils', async (orig) => ({ ...((await orig()) as object), sendUserToast: toast })) // Whether the key ends up settled on the delete after a flush — false stands for a // form that keeps queueing writes behind it. let settles = true @@ -20,6 +22,7 @@ import { settleDraftAfterWrite, UserDraft } from './userDraft.svelte' beforeEach(() => { settles = true + toast.mockClear() discard.mockClear() vi.spyOn(UserDraft, 'discard').mockImplementation(discard as any) }) @@ -65,14 +68,21 @@ describe('settleDraftAfterWrite', () => { expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) }) - // The form stays editable across the delete's own request, so an edit made then - // parks a write that would put the draft back — under a path the item has left, - // once the callers re-key. The delete is re-sent while that is the case, and - // bounded, because a form being typed into can always add one more. - it('re-sends the delete while the key will not settle on it, and gives up bounded', async () => { + // A rename suspends the old key first, so nothing can queue behind the delete and + // re-sending it could only repeat a request the server already refused. What is + // left is a draft under a path the item has left, which has to be reported. + it('sends the delete once on a rename and reports one that will not land', async () => { settles = false await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) - expect(discard).toHaveBeenCalledTimes(3) + expect(discard).toHaveBeenCalledTimes(1) + expect(toast).toHaveBeenCalledTimes(1) + }) + + // A create's form cell is the detached handle an empty path gets, wired to no + // key: every request made for one is addressed to `/drafts/update//`. + it('settles nothing for a create', async () => { + await settleDraftAfterWrite('variable', sent, undefined, '', 'u/me/a', OPTS) + expect(discard).not.toHaveBeenCalled() }) // A released handle reads as no cell at all. That is not somebody's newer edit — @@ -82,13 +92,14 @@ describe('settleDraftAfterWrite', () => { expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS) }) - // Retrying resets the cell to what was written, so it may only chase a path the - // item has left. On an unchanged path an edit made during the delete is the - // user's, and re-sending would revert it. - it('does not retry over an edit made on an unchanged path', async () => { + // On an unchanged path an edit made during the delete is the user's: the key is + // left accepting writes, and a draft standing there is the intended outcome + // rather than something to report. + it('leaves an edit made during the delete on an unchanged path', async () => { settles = false await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/a', OPTS) expect(discard).toHaveBeenCalledTimes(1) + expect(toast).not.toHaveBeenCalled() }) it('sends it once when the key settles', async () => { From 6516b836fd75a5822c4e505d1c4fb9d5e2a2d591 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 10:13:03 +0200 Subject: [PATCH 56/69] fix(drafts): report a mutation whose draft cleanup failed, and record a released cell only during a save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion listener that refreshes and closes hosted editors runs only for a tool that returned, so a draft cleanup throwing after the entity was deployed or deleted left the tab open on state that no longer exists. The cleanup cannot undo the mutation either, so both tools now report it in their result instead of throwing it as if nothing had happened. Releasing a cell recorded its value for every resource and variable, though only a settle waiting on a write in flight ever reads one — routine editor use retained large resource values and decrypted secrets for the tab's lifetime. The editors now open a window around their save, and only a release inside one is remembered; closing it drops what it held. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 8 +++ .../src/lib/components/VariableEditor.svelte | 8 +++ .../copilot/chat/global/core.test.ts | 19 ++++++ .../components/copilot/chat/global/core.ts | 41 +++++++++-- frontend/src/lib/userDraft.svelte.ts | 68 +++++++++++-------- .../src/lib/userDraftSettleAfterWrite.test.ts | 21 ++++++ 6 files changed, 129 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 56540a029f..542ac1c9c6 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -16,6 +16,7 @@ UserDraft, draftValuesEqual, flushDraftDelete, + beginDraftSettleWindow, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -366,6 +367,11 @@ // what an earlier one wrote is deployed — a caller told nothing about it would // stay pointed at a path the item has moved off. const written: { ws: string; path: string }[] = [] + // A host left mid-write releases the cell each settle below reads; only inside + // this window is what it was holding remembered. + const closeSettleWindows = payloads.map(({ ws }) => + beginDraftSettleWindow('resource', from, { workspace: ws }) + ) try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -424,6 +430,8 @@ // paths, listed and editable from there — moving one onto a rename it never // wrote would be worse than not showing it here. return { written, ok: false } + } finally { + for (const close of closeSettleWindows) close() } } diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 20e11feeca..abf8d69fc0 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -26,6 +26,7 @@ UserDraft, draftValuesEqual, flushDraftDelete, + beginDraftSettleWindow, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -311,6 +312,11 @@ // is done: per workspace, each carrying its own, so a linked workspace's rename // moves the tabs acting on it and no others. const written: { ws: string; path: string }[] = [] + // A host left mid-write releases the cell each settle below reads; only inside + // this window is what it was holding remembered. + const closeSettleWindows = payloads.map(({ ws }) => + beginDraftSettleWindow('variable', from ?? '', { workspace: ws }) + ) try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -396,6 +402,8 @@ editVariable(actingPath) if (shownWs) selected = shownWs } + } finally { + for (const close of closeSettleWindows) close() } } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index a6c1d763f8..c13f8f2e0e 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2160,6 +2160,25 @@ describe('global AI tools', () => { expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(true) }) + // The listener that refreshes hosted editors runs only for a tool that returned, + // so a throw after the entity is gone leaves a tab open on an item that no longer + // exists — and the cleanup cannot un-delete it either way. + it('reports a delete whose draft cleanup failed instead of throwing', async () => { + const path = 'f/resources/deleted-with-stuck-draft' + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'localhost' } + }) + // The cleanup is a `value: null` draft write, so failing that write fails it. + failingWrites.add(`resource:${path}`) + + const result = await callGlobalTool('delete_workspace_item', { type: 'resource', path }) + + expect(ResourceService.deleteResource).toHaveBeenCalled() + expect(JSON.parse(result).message).toMatch(/draft could NOT be cleared/) + }) + // Nothing consumes a marker outside a session, so one can outlive the item it // was about. Recreating the item must void it, or the next action on the // recreated item reads it and sends a perfectly valid editor back to its list. diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index fd66e6d7c0..22127bce76 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -7578,7 +7578,9 @@ async function deployDraft( // fork comparisons before the fallible draft cleanup below. invalidateWorkspaceComparison(workspace) - await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + const draftIssue = await clearDraftAfterMutation(workspace, type, path, triggerKind, { + preserveLiveDraft: true + }) // Move the chat's mask entry to the deployed path: a draft-only item's // synthetic storage key never exists deployed, so the entry would otherwise @@ -7611,9 +7613,15 @@ async function deployDraft( return JSON.stringify( { success: true, - message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.${ - deployNote ? ` ${deployNote}` : '' - }`, + message: [ + `Deployed draft ${type} "${path}" to the workspace.`, + draftIssue + ? `The draft could NOT be removed (${draftIssue}), so the item may still show unsaved changes.` + : 'Draft removed.', + deployNote + ] + .filter(Boolean) + .join(' '), type, path, triggerKind @@ -7716,6 +7724,23 @@ async function deployedItemExists( } } +/** + * Clear the draft of an item whose deployed state has already changed. A failure + * here cannot undo that change, and throwing would report the mutation as not + * having happened — to the model, and to the hosts, which hear about one only + * from a tool that returned. Reported in the result instead. + */ +async function clearDraftAfterMutation( + ...args: Parameters +): Promise { + try { + await deleteGlobalDraft(...args) + return undefined + } catch (e) { + return e instanceof Error ? e.message : String(e) + } +} + async function deleteWorkspaceItem( args: { type: WorkspaceItemType; path: string; trigger_kind?: TriggerKind }, ctx: WriteDraftCtx @@ -7759,7 +7784,7 @@ async function deleteWorkspaceItem( // are no longer trustworthy (same rule as deploy success). Before the // draft cleanup: a cleanup failure must not leave stale comparisons. invalidateWorkspaceComparison(workspace) - await deleteGlobalDraft(workspace, type, path, triggerKind) + const draftIssue = await clearDraftAfterMutation(workspace, type, path, triggerKind) // Record the deletion in the chat's modified-items mask. In a fork this leaves a // reviewable "removed" diff vs the parent that stays scoped to this chat. Keyed @@ -7779,7 +7804,11 @@ async function deleteWorkspaceItem( return JSON.stringify( { success: true, - message: `Deleted ${type} "${path}" from the workspace. Any matching draft was also cleared.`, + message: + `Deleted ${type} "${path}" from the workspace. ` + + (draftIssue + ? `Its draft could NOT be cleared (${draftIssue}), so the path may still list one.` + : 'Any matching draft was also cleared.'), type, path, triggerKind diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 401b770023..9c7f545e36 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -142,17 +142,39 @@ function noteMarker(set: Set, key: string): void { set.delete(oldest) } } -// Only the kinds whose settle can read the record back. For the rest, recording one -// would retain a whole flow, script or app draft that nothing will ever ask about. -const RELEASE_RECORDED_KINDS: readonly UserDraftItemKind[] = ['resource', 'variable'] - /** - * What a cell held when its last holder let go: an editor stays editable while its - * save is in flight, so a tab left in that window releases a cell carrying an edit - * newer than the write, and no handle is left to read it from. Same cap and reason - * as the marker sets above. + * What a cell held when its last holder let go, recorded only while a write it + * could be newer than is in flight (see `beginDraftSettleWindow`). Outside one a + * release is an editor simply closing, and remembering its value would retain a + * whole resource — or a variable's decrypted secret — for the tab's lifetime. */ const releasedValues = new Map() +/** Keys with a write in flight, by how many are. */ +const settleWindows = new Map() + +/** + * Open the window in which a release of this cell is worth remembering: the editor + * stays editable while its save is in flight, so a tab left then hands the settle + * that follows a cell nothing holds any more. Pair with the returned disposer. + */ +export function beginDraftSettleWindow( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions +): () => void { + const mk = mapKey(resolveWorkspace(opts), itemKind, path) + settleWindows.set(mk, (settleWindows.get(mk) ?? 0) + 1) + return () => { + const left = (settleWindows.get(mk) ?? 0) - 1 + if (left > 0) { + settleWindows.set(mk, left) + return + } + settleWindows.delete(mk) + // Nothing is left to read it: the settle it was recorded for is over. + releasedValues.delete(mk) + } +} /** Read once: what it describes is settled by the caller that reads it. */ function takeReleasedValue( itemKind: UserDraftItemKind, @@ -165,15 +187,6 @@ function takeReleasedValue( return value } -function noteReleasedValue(key: string, value: unknown): void { - releasedValues.set(key, value) - while (releasedValues.size > MAX_WRITE_MARKERS) { - const oldest = releasedValues.keys().next().value - if (oldest === undefined || oldest === key) break - releasedValues.delete(oldest) - } -} - const liveEditorDrafts = new Map() /** * Map keys whose entry should start `syncSuspended` on acquire. Lets @@ -329,11 +342,9 @@ export async function settleDraftAfterWrite( const diverged = held !== undefined && !draftValuesEqual(held, written) if (savedPath === fromPath && diverged) return // The form stays editable across the delete's own request, so a keystroke made - // then queues a write behind it. Under an unchanged path that write is the - // user's and is meant to stand; under a path the item has left it would put the - // draft back where no editor is, so the old key stops accepting writes for the - // length of the delete — which is what makes one attempt final. The edit stays - // in the form, and could not have followed the rename in either case. + // then queues a write behind it: under an unchanged path that write is the + // user's and stands, but under a path the item has left it would put the draft + // back where no editor is. Suspending that key is what makes one attempt final. const moved = savedPath !== fromPath if (moved) UserDraft.stopSync(itemKind, fromPath, opts) try { @@ -346,11 +357,10 @@ export async function settleDraftAfterWrite( if (moved) UserDraft.restartSync(itemKind, fromPath, opts) } if (!moved) return - // A rename whose delete could not land — a conflict, or a failed request; no - // number of retries changes either. What is left is a draft-only item at a path - // nothing is editing. Callers still follow the rename, since the item really is - // at `savedPath` and leaving them behind would strand the editor too, so this is - // the only thing that says the leftover is there to be discarded. + // A conflict or a failed request, which no retry changes: what is left is a draft + // at a path nothing is editing. Callers still follow the rename — the item is at + // `savedPath`, and leaving them behind would strand the editor too — so this is + // the only thing that says the leftover is there. sendUserToast(`Saved, but the draft left at ${fromPath} could not be cleared`, true) } @@ -1076,9 +1086,7 @@ function releaseEntry(mk: string): void { // only here, once, at refcount 0. This is what lets multiple holders (warm // session previews + the nav editor) share the entry and drop in any order. if (entry.count <= 0) { - if (RELEASE_RECORDED_KINDS.includes(entry.itemKind)) { - noteReleasedValue(mk, snapshotDraftValue(entry.state.val)) - } + if (settleWindows.has(mk)) releasedValues.set(mk, snapshotDraftValue(entry.state.val)) // The live entry was authoritative while mounted; once gone, drop any // cached write for this key so a later read falls back to the server // rather than a value the editor may have changed in the meantime. diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts index d2be6ad0dc..ac2bc645ad 100644 --- a/frontend/src/lib/userDraftSettleAfterWrite.test.ts +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -20,11 +20,18 @@ vi.mock('./userDraftDbSyncer.svelte', () => ({ import { settleDraftAfterWrite, UserDraft } from './userDraft.svelte' +const stopSync = vi.fn() +const restartSync = vi.fn() + beforeEach(() => { settles = true toast.mockClear() discard.mockClear() + stopSync.mockClear() + restartSync.mockClear() vi.spyOn(UserDraft, 'discard').mockImplementation(discard as any) + vi.spyOn(UserDraft, 'stopSync').mockImplementation(stopSync as any) + vi.spyOn(UserDraft, 'restartSync').mockImplementation(restartSync as any) }) const OPTS = { workspace: 'ws' } @@ -102,6 +109,20 @@ describe('settleDraftAfterWrite', () => { expect(toast).not.toHaveBeenCalled() }) + // What makes the single attempt safe: the moved-from key stops accepting writes + // for the length of the delete, so a keystroke cannot queue behind it. Dropping + // the bracket would leave every other case here green. + it('suspends the key it is deleting, and only for a rename', async () => { + await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) + expect(stopSync).toHaveBeenCalledWith('variable', 'u/me/a', OPTS) + expect(restartSync).toHaveBeenCalledWith('variable', 'u/me/a', OPTS) + stopSync.mockClear() + restartSync.mockClear() + await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/a', OPTS) + expect(stopSync).not.toHaveBeenCalled() + expect(restartSync).not.toHaveBeenCalled() + }) + it('sends it once when the key settles', async () => { await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS) expect(discard).toHaveBeenCalledTimes(1) From 25480bc7f27fbb57eb46f009f471f23f25f0dddc Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 10:30:28 +0200 Subject: [PATCH 57/69] fix(drafts): keep a released cell readable for every save still in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two saves of the same cell can be in flight at once, and consuming the record on the first settle left the second reading the released cell as "nothing newer" — so it deleted the edit the first had just preserved. The window that recorded it already owns its lifetime and drops it when the last save closes, so the read does not have to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/userDraft.svelte.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 9c7f545e36..2684c64220 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -175,16 +175,17 @@ export function beginDraftSettleWindow( releasedValues.delete(mk) } } -/** Read once: what it describes is settled by the caller that reads it. */ -function takeReleasedValue( +/** + * Only reads: the window that recorded it owns its lifetime. Two saves of the same + * cell can be in flight at once, and the second settle has to see the same edit the + * first did, or it reads the released cell as "nothing newer" and deletes it. + */ +function releasedValue( itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions ): V | undefined { - const mk = mapKey(resolveWorkspace(opts), itemKind, path) - const value = releasedValues.get(mk) as V | undefined - releasedValues.delete(mk) - return value + return releasedValues.get(mapKey(resolveWorkspace(opts), itemKind, path)) as V | undefined } const liveEditorDrafts = new Map() @@ -338,7 +339,7 @@ export async function settleDraftAfterWrite( // nothing is watching any more — except when the release itself is what took // the handle away mid-write, in which case what it held still speaks for the // user. Absent both, there is nothing newer to protect. - const held = live !== undefined ? live : takeReleasedValue(itemKind, fromPath, opts) + const held = live !== undefined ? live : releasedValue(itemKind, fromPath, opts) const diverged = held !== undefined && !draftValuesEqual(held, written) if (savedPath === fromPath && diverged) return // The form stays editable across the delete's own request, so a keystroke made From 365e59b0506dd92262e09d6de4193131ec6e427f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 10:48:22 +0200 Subject: [PATCH 58/69] fix(drafts): void a removal marker the deployed item outlived, and leave the released record one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker nothing read outlives the item it was about: the path can be deployed again from another tab or client, whose draft never touches this tab's cell. The next discard there reverts the item rather than removing it, and would otherwise spend the stale marker and send that item's editor back to its list. The released record's lifetime is the settle window's alone now — `acquireEntry` also dropping it said there were two owners, and a live entry answers for itself anyway, so the settle never reads the record while one exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../copilot/chat/global/core.test.ts | 27 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 5 ++++ frontend/src/lib/userDraft.svelte.ts | 11 +++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index c13f8f2e0e..789505ec68 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2200,6 +2200,33 @@ describe('global AI tools', () => { expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false) }) + // A marker nothing read outlives the item it was about: the path can be deployed + // again from another tab or client, and the next discard there — a revert, not a + // removal — would otherwise spend it and send that item's editor away. + it('voids a standing removal marker when the path is deployed again', async () => { + const path = 'f/resources/discard-then-deployed' + await callGlobalTool('write_resource', { + path, + resource_type: 'postgresql', + value: { host: 'localhost' } + }) + await callGlobalTool('discard_local_draft', { type: 'resource', path }) + + // Deployed since from another client, with a draft of its own — neither reaches + // this tab's cell, so nothing has cleared the marker. Discarding that draft + // reverts the item rather than removing it. + vi.mocked(ResourceService.existsResource).mockResolvedValue(true) + seedBackendDraft('resource', path, { + path, + value: { host: 'elsewhere' }, + resource_type: 'postgresql' + }) + await callGlobalTool('discard_local_draft', { type: 'resource', path }) + + expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false) + vi.mocked(ResourceService.existsResource).mockResolvedValue(false) + }) + // "Create a resource, then never mind": delete_workspace_item must reject a path // that was never deployed, before the confirmation card — otherwise the user // confirms a workspace mutation that 404s past the draft cleanup, leaving the diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 22127bce76..d8f5079587 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6193,6 +6193,11 @@ async function discardLocalDraft( if (removed) { UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace }) } + } else if (discardedKind) { + // The item is deployed, so this discard reverted it rather than removing it — + // which also settles any marker still standing from an earlier one at this + // path, recorded when nothing deployed was there and never read. + UserDraft.clearDraftOnlyDiscard(discardedKind, storagePath, { workspace }) } // The chat's touch on the item is undone — drop it from the mask so a diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 2684c64220..36801d36fb 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -588,6 +588,16 @@ export const UserDraft = { noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path)) }, + /** + * The item at this path is there, whatever an earlier discard recorded. Nothing + * consumes a marker outside a session, so one can outlive the item it was about + * — recreated from another tab, another client — and be spent by the next action + * on the item that took its place. + */ + clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path)) + }, + /** Whether the last discard for this cell removed the item outright (see * {@link recordDraftOnlyDiscard}), clearing the record. */ takeDraftOnlyDiscard( @@ -950,7 +960,6 @@ function acquireEntry( canBeDisabled = false ): void { const mk = mapKey(workspace, itemKind, path) - releasedValues.delete(mk) const existing = entries.get(mk) if (existing) { existing.count++ From 822906e3cf75a703559349d1f5021e3eeb7b6927 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 11:05:08 +0200 Subject: [PATCH 59/69] fix(resources,variables): serialize an inline editor's saves Rendered inline there is no drawer to close over the Save button, and dirtiness alone kept it enabled while a write was in flight: a second click started an unserialized second create/update, and if the older request finished last its snapshot overwrote the newer deployment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/ResourceEditorDrawer.svelte | 81 ++++++++++--------- .../src/lib/components/VariableEditor.svelte | 10 ++- .../copilot/chat/global/core.test.ts | 50 +++++++----- 3 files changed, 84 insertions(+), 57 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index f2f888915a..41b41bdcf6 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -61,6 +61,10 @@ // truth (see editorBody). let editorGeneration = $state(0) let canSave = $state(true) + // A save in flight. Inline there is no drawer to close over the button, so a + // second click would start an unserialized second write: the older snapshot + // lands last and overwrites the newer deployment. + let savePending = $state(false) let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) @@ -184,8 +188,7 @@ // a linked one: the discard deletes that workspace's draft, so that is whose // tabs it is about. const fromWs = selected ?? effectiveWorkspace - if ((await resourceEditor?.discardLocalDraft()) === false && from) - onRemoved?.(from, fromWs) + if ((await resourceEditor?.discardLocalDraft()) === false && from) onRemoved?.(from, fromWs) }} disabled={!canWriteSelected} /> @@ -224,41 +227,47 @@ unifiedSize="md" startIcon={{ icon: Save }} on:click={async () => { - // Where this save started. Read before the await: an inline host can re-point - // the editor at another resource, or re-scope the session to another - // workspace, while the write is in flight. - const from = path - const fromWs = effectiveWorkspace - // The workspace whose version is on screen, which `WsSpecificVersions` can - // make a linked one: a rename made there is that workspace's alone, but the - // form is showing it, so the form follows it. - const shownWs = selected - // 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 saving = resourceEditor?.saveWritten() - drawer?.closeDrawer() - // What landed, per workspace, from the editor rather than the form: the form may - // be showing a linked workspace's variant, whose rename is not this host's. - const written = (await saving) ?? [] - if (written.length === 0) return - const submitted = - written.find((w) => w.ws === shownWs)?.path ?? written.find((w) => w.ws === fromWs)?.path - // Follow a rename: rendered inline there is no drawer to close, so the mounted - // editor stays, and the key below remounts it on the path the item moved to. - // The editor adopts its own new baseline, so a same-path save needs nothing - // here — remounting it would re-read over an edit made during the write. - // Only while this is still the resource it saved: re-pointed mid-write, - // `path` and the mounted editor are another one's. And only a move: pointing a - // create at the path it just made re-keys the blank form's cell onto it, and - // the mirror then posts that blank state as the new item's draft. - if (path && path === from && submitted) path = submitted - // One report per workspace written, each carrying its own — a linked - // workspace's rename moves the tabs acting on it and no others. Reported - // even when this drawer has moved on: the write is a fact about the item. - for (const w of written) onSaved?.(w.path, from, w.ws) + if (savePending) return + savePending = true + try { + // Where this save started. Read before the await: an inline host can re-point + // the editor at another resource, or re-scope the session to another + // workspace, while the write is in flight. + const from = path + const fromWs = effectiveWorkspace + // The workspace whose version is on screen, which `WsSpecificVersions` can + // make a linked one: a rename made there is that workspace's alone, but the + // form is showing it, so the form follows it. + const shownWs = selected + // 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 saving = resourceEditor?.saveWritten() + drawer?.closeDrawer() + // What landed, per workspace, from the editor rather than the form: the form may + // be showing a linked workspace's variant, whose rename is not this host's. + const written = (await saving) ?? [] + if (written.length === 0) return + const submitted = + written.find((w) => w.ws === shownWs)?.path ?? written.find((w) => w.ws === fromWs)?.path + // Follow a rename: rendered inline there is no drawer to close, so the mounted + // editor stays, and the key below remounts it on the path the item moved to. + // The editor adopts its own new baseline, so a same-path save needs nothing + // here — remounting it would re-read over an edit made during the write. + // Only while this is still the resource it saved: re-pointed mid-write, + // `path` and the mounted editor are another one's. And only a move: pointing a + // create at the path it just made re-keys the blank form's cell onto it, and + // the mirror then posts that blank state as the new item's draft. + if (path && path === from && submitted) path = submitted + // One report per workspace written, each carrying its own — a linked + // workspace's rename moves the tabs acting on it and no others. Reported + // even when this drawer has moved on: the write is a fact about the item. + for (const w of written) onSaved?.(w.path, from, w.ws) + } finally { + savePending = false + } }} - disabled={!canSave} + disabled={!canSave || savePending} > Save diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index abf8d69fc0..acd47bad69 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -283,7 +283,14 @@ form?.setCode(getV.value ?? '') } + // A save in flight. Inline there is no drawer to close over the button, so a + // second click would start an unserialized second write: the older snapshot + // lands last and overwrites the newer deployment. + let savePending = $state(false) + async function save(): Promise { + if (savePending) return + savePending = true // Everything the writes need, read before the first await. An inline host can // re-point this editor at another variable mid-flight, and every one of these // would then be that variable's: the writes would send its state under this @@ -404,6 +411,7 @@ } } finally { for (const close of closeSettleWindows) close() + savePending = false } } @@ -448,7 +456,7 @@ {/if} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index acd47bad69..4e2765b2f8 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -27,6 +27,7 @@ draftValuesEqual, flushDraftDelete, beginDraftSettleWindow, + isDraftSaving, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -283,14 +284,15 @@ form?.setCode(getV.value ?? '') } - // A save in flight. Inline there is no drawer to close over the button, so a - // second click would start an unserialized second write: the older snapshot - // lands last and overwrites the newer deployment. - let savePending = $state(false) + // A save in flight for this variable, in this editor or another holding the same + // cell: inline there is no drawer to close over the button, and duplicate tabs + // and warm sessions mount several editors over one item. Two writes at once and + // the older one landing last overwrites the newer deployment. + const saveInFlight = $derived( + dirtyWorkspaces.some((ws) => isDraftSaving('variable', editPath, { workspace: ws })) + ) async function save(): Promise { - if (savePending) return - savePending = true // Everything the writes need, read before the first await. An inline host can // re-point this editor at another variable mid-flight, and every one of these // would then be that variable's: the writes would send its state under this @@ -320,10 +322,13 @@ // moves the tabs acting on it and no others. const written: { ws: string; path: string }[] = [] // A host left mid-write releases the cell each settle below reads; only inside - // this window is what it was holding remembered. - const closeSettleWindows = payloads.map(({ ws }) => - beginDraftSettleWindow('variable', from ?? '', { workspace: ws }) - ) + // this window is what it was holding remembered. It is also what says a write + // is in flight for this cell, so a second editor on it cannot start one — + // checked before the first is opened, or this would see its own. + if (payloads.some(({ ws }) => isDraftSaving('variable', from, { workspace: ws }))) return + const closeSettleWindows = from + ? payloads.map(({ ws }) => beginDraftSettleWindow('variable', from, { workspace: ws })) + : [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -411,7 +416,6 @@ } } finally { for (const close of closeSettleWindows) close() - savePending = false } } @@ -456,7 +460,7 @@ {/if}