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)