merge: bring the in-realm entity editor hosting onto the item store

Keeps this branch's editors; takes the session hosting (router, tab host, entity
views, session page) from in-realm-entity-editors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF
This commit is contained in:
Ruben Fiszel
2026-09-15 08:47:28 +02:00
co-authored by Claude Opus 5
25 changed files with 1817 additions and 57 deletions
@@ -235,6 +235,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.
// 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.
@@ -2158,6 +2158,107 @@ 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)
})
// 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.
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)
})
// 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.
describe('a removal marker the deployed item outlived', () => {
// The suite's beforeEach clears calls but not implementations, so the
// `existsResource` override below goes back to its factory default here rather
// than inline, where a failing assertion would leak it into every later test.
afterEach(() => {
vi.mocked(ResourceService.existsResource).mockResolvedValue(false)
})
it('is voided 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)
})
})
// "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
@@ -6589,6 +6589,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<boolean> {
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
@@ -6605,16 +6624,40 @@ async function discardLocalDraft(
throw new Error(`No draft found for ${type} "${path}".`)
}
// 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)
const removesItem = !!discardedKind && !(await hasDeployedItem(workspace, type, path))
await deleteGlobalDraft(workspace, type, path, triggerKind)
// 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 query = { workspace, itemKind: discardedKind, path: storagePath }
// Settled AND settled on the delete: an upsert queued behind it displaces the
// delete, and waiting for the chain then reports idle on a draft that is back.
const removed =
UserDraftDbSyncer.getState(query).state === 'none' &&
UserDraftDbSyncer.lastLandedWasDelete(query)
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
// 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, {
@@ -7989,12 +8032,23 @@ async function deployDraft(
}
}
// Where the drawer kinds land is the draft's own path field, not the key it is
// stored under: a hosted editor renames a draft-only item by editing the config,
// which leaves the draft where it was. The script/flow/app branches resolve their
// own target above.
if (type !== 'script' && type !== 'flow' && type !== 'app') {
const draftPath = (draft.value as { path?: string } | undefined)?.path
if (draftPath) deployedPath = draftPath
}
// Deployed state moved for EVERY branch above (some bypass
// deployDraftToWorkspace, which invalidates on its own path) — evict cached
// 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
@@ -8027,11 +8081,19 @@ 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,
// Where it landed, which a rename staged in the draft moves off `path`.
deployed_path: deployedPath,
triggerKind
},
null,
@@ -8132,6 +8194,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<typeof deleteGlobalDraft>
): Promise<string | undefined> {
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
@@ -8175,7 +8254,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
@@ -8195,7 +8274,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
@@ -765,10 +765,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, result: string) => void)
| undefined
export function setToolCompletionListener(
fn: ((toolName: string, args: any) => void) | undefined
fn: ((toolName: string, args: any, workspace: string, result: string) => void) | undefined
): void {
toolCompletionListener = fn
}
@@ -797,7 +799,11 @@ async function callTool<T>({
)
}
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 — and the result, which
// is where a tool says what it did that its args do not, such as the path a
// deploy landed on after a rename staged in the draft.
toolCompletionListener?.(functionName, args, workspace, result)
return result
}
@@ -12,12 +12,17 @@
import type { SessionRuntime } from './sessionRuntime.svelte'
import { Loader2 } from 'lucide-svelte'
import {
entityListHref,
entityListPage,
resolvePreviewTab,
parsePreviewItemRoute,
parsePreviewSelectedId,
showsView
} from './previewRouter'
import type { EntityEditorKind } from './previewRouter'
import type { EntityToolEffect } from './previewReload'
import { withMenuHidden } from './sessionMode.svelte'
import { pageHref, RUNS_PATH } from './previewPaths'
import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte'
import RunFormPreviewSlot from './RunFormPreviewSlot.svelte'
import { setOverlayHost } from '../common/overlayHost.svelte'
@@ -33,7 +38,8 @@
darkMode,
fullscreen = false,
onNavigate,
onLoad
onLoad,
onEntityWritten
}: {
tab: SessionPreviewTab
session: Session | undefined
@@ -59,6 +65,21 @@
onNavigate: (item: WorkspaceItem) => void
/** Iframe finished loading — the page reads back its observed location. */
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 — `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
/** Whether the editor that reported is still the one mounted on this tab. */
fromLive?: boolean
}) => void
} = $props()
// Editor vs iframe is decided purely from the tab URL (see resolvePreviewTab):
@@ -83,6 +104,11 @@
let frame: HTMLIFrameElement | undefined = $state()
// 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
// 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.
@@ -103,12 +129,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.
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, 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 === 'refresh') entityNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -141,6 +176,79 @@
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({
// 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: entityListHref(whereIs(tab)),
label: page.label
})
}
: undefined
)
// "View runs" from a hosted editor: the Runs page is a preview page like any
// other, so it belongs in the preview rather than in the top-level document the
// anchor would navigate — and the frame gives it the acting workspace. Re-points
// this tab, like the way back to the list: the runs are where the editor was.
const viewRuns = $derived(
runtime
? (query: string) =>
runtime.previewTabs.navigate({
type: 'page',
href: `${pageHref(RUNS_PATH)}?${query}`,
label: 'Runs'
})
: undefined
)
// 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) =>
onEntityWritten({
kind,
path: fromPath,
workspace: fromWs,
fromSessionId: session?.id,
fromTabId: tab.id
})
: undefined
)
const reportSaved = $derived(
slot.kind === 'entity'
? (
kind: EntityEditorKind,
newPath: string,
fromPath: string,
fromWs: string,
fromLive: boolean
) =>
onEntityWritten({
kind,
path: fromPath,
workspace: fromWs,
to: newPath,
fromSessionId: session?.id,
fromTabId: tab.id,
fromLive
})
: 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
@@ -311,6 +419,60 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'entity' && mounted}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
<!-- Dynamic import for the same reason as the editors above: these pull in
the runnable pickers and the resource-type schema forms. Keyed on the
refresh nonce so a dropped draft cell remounts the editor (see reload). -->
<!-- The path is part of the key: a tab re-pointed at another row of the same
list keeps this block otherwise, and the view that stays would answer for
an editor its own `{#key path}` has already replaced. -->
{#key `${workspaceId}#${entityNonce}#${slot.path}`}
{#if slot.entityKind === 'trigger_schedule'}
{#await import('./ScheduleEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default
path={slot.path}
{workspaceId}
onBack={backToList}
onRemoved={(from, ws) => reportRemoved?.('trigger_schedule', from, ws)}
onSavedTo={(to, from, ws, live) =>
reportSaved?.('trigger_schedule', to, from, ws, live)}
onViewRuns={viewRuns}
/>
{/await}
{:else if slot.entityKind === 'resource'}
{#await import('./ResourceEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default
path={slot.path}
{workspaceId}
onBack={backToList}
onRemoved={(from, ws) => reportRemoved?.('resource', from, ws)}
onSavedTo={(to, from, ws, live) => reportSaved?.('resource', to, from, ws, live)}
/>
{/await}
{:else if slot.entityKind === 'variable'}
{#await import('./VariableEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default
path={slot.path}
{workspaceId}
onBack={backToList}
onRemoved={(from, ws) => reportRemoved?.('variable', from, ws)}
onSavedTo={(to, from, ws, live) => reportSaved?.('variable', to, from, ws, live)}
/>
{/await}
{/if}
{/key}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -0,0 +1,66 @@
<script lang="ts">
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import { untrack, onDestroy } from 'svelte'
let {
path,
workspaceId,
onBack,
onRemoved,
onSavedTo
}: {
/** 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
/** 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, 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,
/** Whether the editor that saved is still the one mounted here. A tab left
* and returned to the same item during the write has another in its place,
* loaded before the save landed, which cannot have settled its own baseline. */
fromLive: boolean
) => void
} = $props()
// A tab left and reopened on the same item mounts a new view over this one; the
// editor that saved is then gone, and what it reports about its baseline is not
// about the form on screen.
let mounted = true
onDestroy(() => (mounted = false))
let editor = $state<ResourceEditorDrawer | undefined>()
// 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))
})
</script>
<ResourceEditorDrawer
bind:this={editor}
useDrawer={false}
workspace={workspaceId}
{onBack}
{onRemoved}
onRestored={(ws, restored) => onSavedTo?.(restored, restored, ws, mounted)}
onSaved={(saved, from, fromWs) => {
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs, mounted)
}}
/>
@@ -0,0 +1,115 @@
<script lang="ts">
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { Button } from '$lib/components/common'
import { ArrowLeft } from 'lucide-svelte'
import { untrack, onDestroy } from 'svelte'
let {
path,
workspaceId,
onBack,
onRemoved,
onSavedTo,
onViewRuns
}: {
/** The schedule this tab edits (the row its location deep-links). */
path: string
/** The session's acting workspace, which the whole trigger subtree reads
* through the `triggerWorkspace` resolver instead of `$workspaceStore`. */
workspaceId: string
/** Back to the list this editor was reached through; the tab replaced it. */
onBack?: () => 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, 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,
/** Whether the editor that saved is still the one mounted here. A tab left
* and returned to the same item during the write has another in its place,
* loaded before the save landed, which cannot have settled its own baseline. */
fromLive: boolean
) => void
/** Show this schedule's runs, given the Runs page's filter query. */
onViewRuns?: (query: string) => void
} = $props()
// A tab left and reopened on the same item mounts a new view over this one; the
// editor that saved is then gone, and what it reports about its baseline is not
// about the form on screen.
let mounted = true
onDestroy(() => (mounted = false))
// Captured at init, so it must read the current prop rather than close over it.
setTriggerWorkspace(() => workspaceId)
let editor = $state<ScheduleEditorInner | undefined>()
// 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. 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
// 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 e = editor
if (!path || !e) return
untrack(() => void e.openEdit(path, false))
})
</script>
<div class="h-full w-full overflow-auto p-4">
<!-- useDrawer=false renders the editor as a Section, the same inline form the
script/flow trigger panel mounts (see SchedulePanel). `customLabel` is that
Section's header, which is where the way back belongs. -->
<!-- Keyed on the path as well: `openEdit` loads asynchronously, and re-pointing a
live editor would leave the previous schedule's load to finish into the new
one's form and deployed baseline. -->
{#key `${path}#${generation}`}
<!-- No `allowDraft`: it switches the toolbar to the trigger-panel branch, whose
deploy button is gated on the `trigger`/`isDeployed` a script or flow editor
supplies alongside a staged trigger. A standalone tab has neither, and would
be left permanently unsavable; this keeps the drawer's plain Save. -->
<ScheduleEditorInner
bind:this={editor}
useDrawer={false}
showDraftBanner
{onRemoved}
{onViewRuns}
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, mounted)
}}
>
{#snippet customLabel()}
<div class="flex flex-row items-center gap-2 min-w-0">
{#if onBack}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArrowLeft }}
on:click={onBack}
title="Back to Schedules"
iconOnly
/>
{/if}
<span class="text-sm font-semibold truncate">Schedule</span>
</div>
{/snippet}
</ScheduleEditorInner>
{/key}
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { untrack, onDestroy } from 'svelte'
let {
path,
workspaceId,
onBack,
onRemoved,
onSavedTo
}: {
/** The variable 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
/** 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, 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,
/** Whether the editor that saved is still the one mounted here. A tab left
* and returned to the same item during the write has another in its place,
* loaded before the save landed, which cannot have settled its own baseline. */
fromLive: boolean
) => void
} = $props()
// A tab left and reopened on the same item mounts a new view over this one; the
// editor that saved is then gone, and what it reports about its baseline is not
// about the form on screen.
let mounted = true
onDestroy(() => (mounted = false))
let editor = $state<VariableEditor | undefined>()
// Selects the variable this tab holds, on the instance the `{#key}` below just
// mounted for it — `editor` is rebound per instance, so this re-runs per path.
$effect(() => {
const e = editor
if (!path || !e) return
untrack(() => e.editVariable(path))
})
</script>
<!-- Keyed on the path: the editor loads asynchronously, and re-pointing a live one
would leave the previous item's load to finish into the new item's state — its
draft cell, its baseline, its permissions. A fresh instance per path has none
of that to overwrite. -->
{#key path}
<VariableEditor
bind:this={editor}
useDrawer={false}
workspace={workspaceId}
{onBack}
{onRemoved}
onSaved={(saved, from, fromWs) => {
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs, mounted)
}}
/>
{/key}
@@ -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<typeof UserDraftDbSyncer.flush>[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
@@ -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
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { toolReloadEffect, tabsToReload } from './previewReload'
import {
toolReloadEffect,
tabsToReload,
strongerEntityEffect,
entityEffectForTab,
effectForWrite,
effectForDiscard
} from './previewReload'
import type { SessionPreviewTab } from './sessionState.svelte'
describe('toolReloadEffect', () => {
@@ -10,6 +17,25 @@ describe('toolReloadEffect', () => {
expect(toolReloadEffect('create_folder', { name: 'f' }).pages).toEqual(['/folders'])
})
// A draft carries its own path field, so a hosted editor renames a draft-only
// item by editing the config and leaves the draft under the key it was stored
// at. The deploy lands on the config's path, and only the tool's own result
// says so — refreshing the args path would remount a tab on an empty one.
it('follows a deploy that landed on a renamed path', () => {
const effect = toolReloadEffect(
'deploy_workspace_item',
{ type: 'schedule', path: 'u/me/old' },
JSON.stringify({ success: true, path: 'u/me/old', deployed_path: 'u/me/new' })
)
expect(effect).toMatchObject({ path: 'u/me/old', to: 'u/me/new', entity: 'refresh' })
})
it('names no destination for an unreadable or absent result', () => {
const args = { type: 'schedule', path: 'u/me/s' }
expect(toolReloadEffect('deploy_workspace_item', args, 'not json').to).toBeUndefined()
expect(toolReloadEffect('deploy_workspace_item', args).to).toBeUndefined()
})
it('maps a trigger write to its kind-specific page', () => {
expect(toolReloadEffect('write_trigger', { kind: 'kafka' }).pages).toEqual(['/kafka_triggers'])
expect(toolReloadEffect('write_trigger', { kind: 'http' }).pages).toEqual(['/routes'])
@@ -27,6 +53,34 @@ 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('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')
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([])
@@ -52,6 +106,77 @@ 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('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'],
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' }
@@ -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,34 +22,136 @@ 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: [] }
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
/** Where it landed, when that is not `path`: a draft carries its own path
* field, so a hosted editor renames a draft-only item by editing the config
* and leaves the draft under the key it was stored at. A tab on `path` has to
* follow, exactly as it does for a rename the editor itself saved. */
to?: string
}
const NO_RELOAD: ToolReloadEffect = { pages: [], entity: 'none' }
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
export function toolReloadEffect(name: string, args: any, result?: string): 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'] }
return { pages: ['/schedules'], entity: 'none', path: itemPath(args) }
case 'write_trigger':
return { pages: triggerPages(args?.kind) }
return { pages: triggerPages(args?.kind), entity: 'none', path: itemPath(args) }
case 'write_resource':
return { pages: ['/resources'] }
return { pages: ['/resources'], entity: 'none', path: itemPath(args) }
case 'write_variable':
return { pages: ['/variables'] }
return { pages: ['/variables'], entity: 'none', path: itemPath(args) }
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':
case 'discard_local_draft':
// 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 'deploy_workspace_item':
return {
pages: pagesForItemType(args?.type, args),
entity: 'refresh',
path: itemPath(args),
to: deployedPath(result)
}
case 'discard_local_draft':
case 'rebase_draft':
return { pages: pagesForItemType(args?.type, args) }
return { pages: pagesForItemType(args?.type, args), entity: 'refresh', path: itemPath(args) }
case 'delete_workspace_item':
return { pages: pagesForItemType(args?.type, args), entity: 'close', path: itemPath(args) }
default:
return NO_RELOAD
}
}
/** The path a deploy reported landing on. Read from the result rather than the
* args because only the tool knows it: the draft's own path field is what it
* deploys at. Anything unparseable leaves the mutation addressed by its args. */
function deployedPath(result: string | undefined): string | undefined {
if (!result) return undefined
try {
const p = (JSON.parse(result) as { deployed_path?: unknown }).deployed_path
return typeof p === 'string' && p ? p : undefined
} catch {
return undefined
}
}
function itemPath(args: any): string | 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. */
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
}
/** 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
}
/** 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 {
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':
@@ -63,10 +172,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). 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<string>
@@ -14,7 +14,9 @@ import {
previewLocationContext,
previewLocationLabel,
resolvePreviewTab,
runFormUrl
runFormUrl,
entityListHref,
entityEditorHref
} from './previewRouter'
describe('drawerAnchorFor', () => {
@@ -293,6 +295,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',
@@ -416,3 +444,20 @@ describe('run form route', () => {
expect(parseRunFormRoute('artifact:abc#Plan')).toBeNull()
})
})
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')
})
})
@@ -1,6 +1,7 @@
import {
ASSETS_PATH,
AUDIT_LOGS_PATH,
drawerHashFor,
FOLDERS_PATH,
GROUPS_PATH,
pageKey,
@@ -117,6 +118,65 @@ 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<Record<string, EntityEditorKind>> = {
[SCHEDULES_PATH]: 'trigger_schedule',
[RESOURCES_PATH]: 'resource',
[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
* 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. */
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
* 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
@@ -490,6 +550,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: 'runform'; toolCallId: string }
| { kind: 'iframe' }
@@ -503,6 +564,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 =
@@ -7,6 +7,7 @@ import {
canonicalizeObservedLoc,
describeLocation,
matchPreviewPage,
parseEntityEditorRoute,
showsView,
parseArtifactRoute,
parsePipelineRoute,
@@ -303,6 +304,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 `#<path>` opening a drawer the user has
// since closed — would never re-fire.
@@ -622,6 +635,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()
}
@@ -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'
@@ -365,6 +365,34 @@ describe('SessionPreviewTabs.open', () => {
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.
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')
})
// 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', () => {
@@ -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
@@ -97,7 +97,7 @@ export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): 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()
+249 -4
View File
@@ -1,8 +1,9 @@
import { get } from 'svelte/store'
import { SvelteMap } from 'svelte/reactivity'
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'
@@ -124,6 +125,87 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
}
const entries = new Map<string, DraftEntry>()
// 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<string>()
// Cells whose last discard removed the item itself (see `takeDraftOnlyDiscard`).
const draftOnlyDiscards = new Set<string>()
// 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<string>, 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)
}
}
/**
* 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<string, unknown[]>()
/** Keys with a write in flight, by how many are. Reactive: it is also what the
* editors disable their Save on. */
const settleWindows = new SvelteMap<string, number>()
/**
* Whether a write is in flight for this cell — in any editor holding it, not just
* the one asking. Duplicate tabs and warm sessions mount several editors over one
* (workspace, kind, path), so a flag kept per editor serializes nothing: two can
* be writing the same item at once, and the older request landing last wins.
*/
export function isDraftSaving(
itemKind: UserDraftItemKind,
path: string | undefined,
opts?: UserDraftOptions
): boolean {
if (!path) return false
return settleWindows.has(mapKey(resolveWorkspace(opts), itemKind, path))
}
/**
* 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)
}
}
/**
* Every value the cell was let go of during the window. All of them: an item
* reopened and closed again releases a freshly loaded baseline, and keeping only
* the last would let it erase the edit before it. Only reads — the window owns
* their lifetime, and a second save settling the cell must see what the first saw.
*/
function releasedDuringWindow<V>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): V[] {
return (releasedValues.get(mapKey(resolveWorkspace(opts), itemKind, path)) ?? []) as V[]
}
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
/**
@@ -280,6 +362,92 @@ 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 async function settleDraftAfterWrite<V>(
itemKind: UserDraftItemKind,
written: V,
live: V | undefined,
fromPath: string,
savedPath: string,
opts?: UserDraftOptions
): Promise<void> {
// 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/<kind>/` and 404s.
if (!fromPath) return
// Anything this cell held during the write counts, not just what it holds now:
// an editor that let go mid-write and was reopened before this ran leaves a
// freshly loaded entry standing over the edit it released, and reading only the
// live one would take that baseline for "nothing newer" and delete the edit.
const newer = (v: V | undefined) => v !== undefined && !draftValuesEqual(v, written)
const diverged =
newer(live) || releasedDuringWindow<V>(itemKind, fromPath, opts).some((v) => newer(v))
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 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 {
// 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 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)
}
/**
* 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,
path: string,
opts?: UserDraftOptions
): Promise<void> {
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 —
* 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<boolean> {
const query = { workspace: resolveWorkspace(opts), itemKind, path }
await flushDraftWrites(itemKind, path, opts)
return (
UserDraftDbSyncer.lastLandedWasDelete(query) &&
UserDraftDbSyncer.getState(query).state === 'none'
)
}
export type UserDraftHandle<V> = {
get draft(): V | undefined
set draft(value: V | undefined)
@@ -290,6 +458,10 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
if (liveItems?.seed(ws, itemKind, path, value)) return
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
@@ -464,18 +636,79 @@ 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.
*/
seed<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
seed<V>(
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)
if (liveItems?.seed(ws, itemKind, path, value)) return
const mk = mapKey(ws, itemKind, path)
// 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) return
if (!entry) {
if (!opts?.baseline) noteMarker(seedMisses, mk)
return
}
if (!opts?.baseline) seedMisses.delete(mk)
entry.seedNextWrite = true
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 {
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(
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
* 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).
@@ -906,6 +1139,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
@@ -934,6 +1171,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,
@@ -963,6 +1202,12 @@ 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 (settleWindows.has(mk)) {
releasedValues.set(mk, [
...(releasedValues.get(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.
+26 -1
View File
@@ -228,6 +228,17 @@ const failures = new SvelteMap<string, string>()
*/
const flushes = new SvelteMap<string, number>()
/**
* Whether the last save that LANDED for a key deleted the draft or wrote one.
* 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<string, 'delete' | 'upsert'>()
/**
* 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
@@ -303,6 +314,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
serverTimestamp: resp.current_timestamp,
localLastSync: lastSync ?? null
})
lastLanded.delete(key)
return
}
// resp.status === 'saved' — advance lastSync (or drop on delete).
@@ -316,6 +328,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
// (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
@@ -535,9 +548,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)
},
/**
@@ -593,6 +608,16 @@ 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, 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'
},
/** Reactive conflict snapshot (if any) for a draft. */
getConflict(query: UserDraftLastSyncQuery): {
readonly conflict: DraftConflictInfo | undefined
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// 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 () => {}),
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'
const run = () => flushDraftDelete('variable', 'u/me/v', { workspace: 'ws' })
beforeEach(() => {
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 with nothing behind it', async () => {
landedDelete = true
expect(await run()).toBe(true)
})
// 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)
})
// 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)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -0,0 +1,181 @@
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
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 {
beginDraftSettleWindow,
isDraftSaving,
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' }
const sent = { path: 'u/me/a', value: 'sent' }
// Duplicate tabs and warm sessions mount several editors over one cell, so what
// says "a write is in flight for this item" cannot live in any of them. The
// window each save opens is per key, and every editor's Save reads it.
describe('isDraftSaving', () => {
it('answers for the cell rather than the editor, until the last save closes', () => {
expect(isDraftSaving('variable', 'u/me/a', OPTS)).toBe(false)
const first = beginDraftSettleWindow('variable', 'u/me/a', OPTS)
const second = beginDraftSettleWindow('variable', 'u/me/a', OPTS)
expect(isDraftSaving('variable', 'u/me/a', OPTS)).toBe(true)
expect(isDraftSaving('variable', 'u/me/b', OPTS)).toBe(false)
first()
expect(isDraftSaving('variable', 'u/me/a', OPTS)).toBe(true)
second()
expect(isDraftSaving('variable', 'u/me/a', OPTS)).toBe(false)
})
// A create's form cell is routed nowhere, so nothing can collide with it.
it('is false for a path a cell cannot be keyed on', () => {
expect(isDraftSaving('variable', '', OPTS)).toBe(false)
expect(isDraftSaving('variable', undefined, OPTS)).toBe(false)
})
})
/**
* 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)
})
// 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(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/<kind>/`.
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 —
// 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)
})
// 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()
})
// 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)
})
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()
})
})
@@ -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 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,
@@ -62,11 +65,25 @@
pageKey,
parseArtifactRoute,
parseRunFormRoute,
entityEditorHref,
entityKindForPage,
entityListHref,
entityListPage,
type EntityEditorKind,
parseEntityEditorRoute,
parsePreviewItemRoute,
previewLocationLabel,
stripBase,
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
import {
toolReloadEffect,
tabsToReload,
entityEffectForTab,
effectForWrite,
effectForDiscard,
type EntityMutation
} from '$lib/components/sessions/previewReload'
import {
leafKeyFor,
loadKind,
@@ -74,6 +91,8 @@
type WorkspaceItemKind
} from '$lib/components/workspacePicker'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
const globalEnabled = isGlobalAiEnabled()
@@ -559,40 +578,169 @@
const tabHosts: Record<string, PreviewTabHost | undefined> = {}
let reloadHandle: ReturnType<typeof setTimeout> | 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<string>()
// 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<string>) {
function reloadTabs(pages: Set<string>, mutations: EntityMutation[]) {
for (const s of warmSessions) {
const owner = getRuntime(s.id)?.previewTabs
if (!owner) continue
for (const tab of tabsToReload(owner.tabs, pages)) {
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 = pageReloadKey(workspace ?? '', '')
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)
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
// 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.
// The tab's own location, so the list it came from keeps its filters.
if (effect === 'close') {
owner.retargetTabTo(tab.id, entityListHref(whereIs(tab)))
} else if (effect === 'refresh' && mountedTabKeys.has(key)) {
tabHosts[key]?.reload({ entity: 'refresh' })
}
}
}
}
// 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
fromSessionId: string | undefined
fromTabId: string
/** Whether the editor that reported is still the one mounted on that tab. */
fromLive?: boolean
}) {
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)
if (stripBase(loc) !== page) continue
const key = tabKey(s.id, tab.id)
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. Queued rather than reloaded here, so one deploy's writes and
// this report collapse into a single reload.
if (!entity) {
queuePageReload(ev.workspace, page)
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))
// 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 settled its own, edit typed mid-write included, which a
// remount would re-read from under — unless it is gone and what replaced it
// loaded before the save landed.
else if (ev.fromLive === false || s.id !== ev.fromSessionId || tab.id !== ev.fromTabId)
tabHosts[key]?.reload({ entity: 'refresh' })
}
}
}
// 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. 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(pageReloadKey(workspace, page))
clearTimeout(reloadHandle)
reloadHandle = setTimeout(flushReload, 500)
}
function flushReload() {
const pages = pendingPages
const mutations = pendingMutations
pendingPages = new Set()
reloadTabs(pages)
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.
$effect(() => {
return UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => {
const page = entityListPage(itemKind as EntityEditorKind)?.path
if (page) queuePageReload(workspace, page)
})
})
$effect(() => {
// Debounced so a burst of writes (the AI editing several files) reloads once.
setToolCompletionListener((name, args) => {
const { pages } = toolReloadEffect(name, args)
setToolCompletionListener((name, args, workspace, result) => {
const { pages, entity, path, to } = toolReloadEffect(name, args, result)
if (pages.length === 0) return
for (const p of pages) pendingPages.add(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
// 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 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 =
!readable ||
name !== 'discard_local_draft' ||
!UserDraft.takeDraftOnlyDiscard(kind!, path!, { workspace })
const effect = effectForDiscard(effectForWrite(entity, seedReachedEditor), itemSurvives)
// A mutation that moved the item is a rename like any other, so it goes
// through the same report an editor's own save makes: the tabs on the old
// path follow it, rather than being refreshed onto one that is now empty.
if (kind && path && to && to !== path) {
entityWritten({ kind, path, to, workspace, fromSessionId: undefined, fromTabId: '' })
} else if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace })
clearTimeout(reloadHandle)
reloadHandle = setTimeout(flushReload, 500)
})
return () => {
clearTimeout(reloadHandle)
pendingPages = new Set()
pendingMutations = []
setToolCompletionListener(undefined)
}
})
@@ -732,10 +880,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_<uuid>` storage
@@ -1094,6 +1253,7 @@
{fullscreen}
onNavigate={navigateEditorTo}
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
onEntityWritten={entityWritten}
/>
{/each}
{/each}