From 09ab9b8e6afcbbd5e5f7e7350a69749676db88e5 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 17 Sep 2026 17:33:44 +0200 Subject: [PATCH] fix: show a page item tab whose item is gone instead of mounting its editor Co-Authored-By: Claude Opus 5 (1M context) --- .../sessions/PageItemEditorView.svelte | 58 ++++++++++++----- .../lib/components/sessions/pageItemLookup.ts | 62 +++++++++++++++++++ 2 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/components/sessions/pageItemLookup.ts diff --git a/frontend/src/lib/components/sessions/PageItemEditorView.svelte b/frontend/src/lib/components/sessions/PageItemEditorView.svelte index 3d1d28e459..db8ec39434 100644 --- a/frontend/src/lib/components/sessions/PageItemEditorView.svelte +++ b/frontend/src/lib/components/sessions/PageItemEditorView.svelte @@ -2,11 +2,19 @@ import { setContext, untrack } from 'svelte' import { Loader2 } from 'lucide-svelte' import { enterpriseLicense } from '$lib/stores' - import { ResourceService } from '$lib/gen' + import { resource } from 'runed' + import { Button } from '$lib/components/common' + import { lookupPageItem } from './pageItemLookup' import VariableEditor from '$lib/components/VariableEditor.svelte' import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte' import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - import { TRIGGER_PAGES, type PageItemRef, type TriggerKind } from './previewPaths' + import { + pageItemKindLabel, + pageItemUrl, + TRIGGER_PAGES, + type PageItemRef, + type TriggerKind + } from './previewPaths' import type { SessionRuntime } from './sessionRuntime.svelte' let { @@ -70,29 +78,35 @@ const t = triggerEditor untrack(() => { v?.editVariable(path) - if (r) void openResource(r, path) + // An agent opens on JSON: the generic form would render its configuration field by + // field and write a default into each one the value leaves out, drafting just by opening. + void r?.initEdit(path, { json: lookup.current?.resourceType === 'ai_agent' }) void t?.openEdit(path, false) }) }) - // An agent is edited as JSON here: the generic form would render its configuration field by - // field and write a default into each one the value leaves out, drafting just by opening. - async function openResource(editor: ResourceEditorDrawer, path: string) { - let resourceType: string | undefined - try { - resourceType = (await ResourceService.getResource({ workspace: workspaceId, path })) - .resource_type - } catch { - // A draft-only resource has no row yet; the editor reads the draft itself. - } - if (editor !== resourceEditor) return - await editor.initEdit(path, { json: resourceType === 'ai_agent' }) - } - // A save can move the item; the tab follows it, which remounts the editor on what was // written. Saving in place remounts it too: the editors keep the pre-save baseline, and // their drawers only ever relied on being closed after a save. let savedNonce = $state(0) + + // Whether the item still exists, deployed or as a draft, read again whenever the editor + // remounts: a delete or a discarded draft reloads the tab too, and the editors have no state + // of their own for an item that is gone. A lookup that fails otherwise is left to the editor, + // which reports its own load errors. + const lookup = resource( + () => ({ ref: item, workspace: workspaceId, reload: `${reloadNonce}:${savedNonce}` }), + ({ ref, workspace }) => + lookupPageItem(ref, workspace).then( + (found) => ({ exists: !!found, resourceType: found?.resource_type as string | undefined }), + () => ({ exists: true, resourceType: undefined }) + ) + ) + + function closeTab() { + const tab = runtime.previewTabs.tabs.find((t) => t.url === pageItemUrl(item)) + if (tab) runtime.previewTabs.close(tab.id) + } function onSaved(path: string | undefined) { if (path && path !== item.path) { runtime.previewTabs.retargetPageItem(item, { ...item, path }) @@ -111,6 +125,16 @@
{#if eeLocked}
This trigger requires an enterprise license.
+ {:else if lookup.loading || !lookup.current} + {@render loading()} + {:else if !lookup.current.exists} +
+

+ {pageItemKindLabel(item)} {item.path} no longer exists in this + workspace. +

+ +
{:else} {#key `${item.kind}:${triggerKey}:${item.path}:${workspaceId}:${reloadNonce}:${savedNonce}`} {#if item.kind === 'variable'} diff --git a/frontend/src/lib/components/sessions/pageItemLookup.ts b/frontend/src/lib/components/sessions/pageItemLookup.ts new file mode 100644 index 0000000000..bee3fc41c1 --- /dev/null +++ b/frontend/src/lib/components/sessions/pageItemLookup.ts @@ -0,0 +1,62 @@ +import { + AmqpTriggerService, + ApiError, + AzureTriggerService, + EmailTriggerService, + GcpTriggerService, + HttpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ResourceService, + ScheduleService, + SqsTriggerService, + VariableService, + WebsocketTriggerService +} from '$lib/gen' +import type { PageItemRef, TriggerKind } from './previewPaths' + +type Get = (args: { workspace: string; path: string; getDraft: boolean }) => Promise + +const TRIGGER_GETS: Record = { + http: (a) => HttpTriggerService.getHttpTrigger(a), + websocket: (a) => WebsocketTriggerService.getWebsocketTrigger(a), + postgres: (a) => PostgresTriggerService.getPostgresTrigger(a), + kafka: (a) => KafkaTriggerService.getKafkaTrigger(a), + nats: (a) => NatsTriggerService.getNatsTrigger(a), + mqtt: (a) => MqttTriggerService.getMqttTrigger(a), + amqp: (a) => AmqpTriggerService.getAmqpTrigger(a), + sqs: (a) => SqsTriggerService.getSqsTrigger(a), + gcp: (a) => GcpTriggerService.getGcpTrigger(a), + azure: (a) => AzureTriggerService.getAzureTrigger(a), + email: (a) => EmailTriggerService.getEmailTrigger(a) +} + +function getFor(ref: PageItemRef): Get { + switch (ref.kind) { + case 'variable': + return (a) => VariableService.getVariable(a) + case 'resource': + return (a) => ResourceService.getResource(a) + case 'schedule': + return (a) => ScheduleService.getSchedule(a) + case 'trigger': + return TRIGGER_GETS[ref.triggerKind] + } +} + +/** A page item as its editor would load it — deployed, or only a draft — or undefined when it + * is neither: deleted, or a draft that was discarded. Any other failure is thrown, for the + * caller to leave to the editor rather than report as a missing item. */ +export async function lookupPageItem( + ref: PageItemRef, + workspace: string +): Promise | undefined> { + try { + return (await getFor(ref)({ workspace, path: ref.path, getDraft: true })) as Record + } catch (e) { + if (e instanceof ApiError && e.status === 404) return undefined + throw e + } +}