Files
windmill/frontend/src/lib/components/sessions/SessionEditorTarget.svelte
T
Ruben FiszelandClaude Opus 4.8 5cde2d5b67 fix(sessions): sync AI-session preview with workspace edits + stop phantom autosave (WIN-2160) (#10061)
* fix(sessions): sync AI-session editor preview with workspace edits + stop phantom autosave (WIN-2160)

Two related draft-sync fixes surfaced by the new AI sessions preview.

1. Session preview went stale after a workspace edit. A session's editor
   runtime cell (content store + loadedPath) outlives the sessions page: it
   survives toggling to workspace mode and MRU tab eviction. The shared
   per-user draft can change while the editor is unmounted — most visibly by
   editing the same item in the classic workspace editor, or from another
   device — but on the next mount the load early-returns on the still-set
   loadedPath and the preview keeps showing the pre-toggle content. Fix:
   invalidate the cell's loadedPath when SessionEditorTarget unmounts, so the
   next mount re-fetches the draft as a clean first load. This also sidesteps
   a Monaco model-reuse race (a force-reload that remounts the editor while
   the old one is still disposing renders a stale model) and prevents the
   outbound draft-sync from posting the stale store back (ready() stays false
   until the reload lands). Applies to all three editor kinds (script, flow,
   raw app) since they share SessionEditorTarget.

2. Opening a deployed script in the full-page editor autosaved a phantom
   draft with no user change. The deployed baseline carries a server-derived
   assets: [] that the editor's draft value never reproduces, so
   draftValuesEqual never matched baseline, discardIf returned false, and the
   settle-time write posted a no-op draft. Fix: ignore assets in the
   draft-vs-baseline comparison (it's derived from content, so it can't mask a
   real change).

Fixes WIN-2160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sessions): condense teardown-invalidation comment to repo comment-length rule

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:19:08 +02:00

183 lines
6.9 KiB
Svelte

<script lang="ts">
import { setContext, untrack, type Snippet } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { UserDraft } from '$lib/userDraft.svelte'
import type { SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
import { useUserDraftSync, type DraftSyncCodec } from './useUserDraftSync.svelte'
import { makeFlowCodec, makeScriptCodec, makeRawAppCodec } from './sessionDraftCodecs'
import { draftFriendlyLeaf } from './previewRouter'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
runtime,
kind,
path,
workspaceId,
effectivePath,
editor,
onNavigate,
isActiveSession = true,
isActiveTab = true
}: {
runtime: SessionRuntime
kind: SessionTargetKind
path: string
workspaceId: string
/**
* The path the live-editor draft should resolve to (the loaded item's own
* path, which may differ from the storage `path` when a draft renames it).
* A getter so the registration tracks it as the store settles.
*/
effectivePath: () => string
/** The heavy editor for this kind; remounted on a data-ready target swap. */
editor: Snippet
onNavigate?: (item: WorkspaceItem) => void
/** A warm-but-hidden session must not claim the live-editor slot. */
isActiveSession?: boolean
/**
* Only the visible editor tab claims the workspace's single live-editor slot
* (one per (workspace, kind)); with several editors open at once, a hidden
* tab must not, else chat actions resolve to the wrong item's path.
*/
isActiveTab?: boolean
} = $props()
// Mark this subtree as the session side panel: editors below detect the
// context to hide their own AI entry points (an AI button here would nest
// sessions), and chat-aware components resolve the session's scoped manager
// instead of the app singleton. The value is captured at init — a reused
// component instance keeps the first runtime's manager — so descendants may
// rely on its presence, not its identity.
setContext('aiChatManager', runtime.manager)
// This tab's own editor cell (per (kind, path)); several tabs can be live at once.
const cell = $derived(
kind === 'flow'
? runtime.flowCell(path)
: kind === 'script'
? runtime.scriptCell(path)
: runtime.rawAppCell(path)
)
const slot = $derived(cell.slot)
function triggerLoad(): Promise<void> {
if (kind === 'flow') return runtime.loadFlow(workspaceId, path)
if (kind === 'script') return runtime.loadScript(workspaceId, path)
return runtime.loadRawApp(workspaceId, path)
}
function buildCodec(): DraftSyncCodec<any> {
if (kind === 'flow')
return makeFlowCodec(
runtime.flowCell(path).store,
runtime.flowCell(path).stateStore,
workspaceId
)
if (kind === 'script') return makeScriptCodec(runtime.scriptCell(path).store, () => path)
return makeRawAppCodec(runtime.rawAppCell(path).store)
}
// Rebuilds when `path` changes so the sync always binds this tab's current
// cell: retargeting the tab (in-editor link / breadcrumb) swaps `path` without
// remounting, and each codec closes over one cell's store.
const codec = $derived(buildCodec())
$effect(() => {
if (workspaceId && path) {
untrack(() => void triggerLoad())
}
})
// The runtime cell (store + `loadedPath`) outlives this component, so a draft
// changed while unmounted (workspace edit, other device) would be masked by
// `triggerLoad`'s early-return on the stale `loadedPath`. Invalidate it on
// teardown so the next mount re-fetches as a clean first load.
$effect(() => {
const c = cell
const p = path
const w = workspaceId
return () => {
if (c.slot.loadedPath === p && c.slot.loadedWorkspace === w) c.slot.loadedPath = undefined
}
})
// Mark this editor as the live editor draft for the session's workspace so
// the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve to this
// path — same registration the regular edit pages do. Only the visible tab of
// the active session claims the (workspace, kind) slot (see prop docs).
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession || !isActiveTab) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: kind,
storagePath: path,
effectivePath: effectivePath()
})
return () => UserDraft.clearLiveEditorDraft(kind, { workspace: workspaceId, storagePath: path })
})
useUserDraftSync({
path: () => path,
workspace: () => workspaceId,
ready: () => slot.loadedPath === path,
codec: () => codec
})
// Stamp the tab's friendly label once this editor's cell knows the item's
// typed/auto name. The page can't read the runtime cell reactively (it lives
// outside the page's reactive root), but this editor — handed `runtime` as a
// prop — can, so it mirrors the name onto the tab model the page does observe.
// Only for a never-deployed item still parked at a `…/draft_<uuid>` storage
// path; a deployed/real path keeps the plain location label.
$effect(() => {
const v = cell.store.val as { path?: string; draft_path?: string } | undefined
const label = draftFriendlyLeaf(path, v?.draft_path ?? v?.path)
runtime.previewTabs.setEditorFriendlyLabel({ kind, path }, label)
})
// Debounced loading affordance for a breadcrumb swap: while the loaded path
// lags the requested `path` (data not landed), keep the old editor visible
// for ~150ms, then dim it under a spinner. Cleared the moment the load
// settles. The first-load / force-reload window (loadedPath === undefined)
// uses the un-debounced branch in the markup instead.
let showOverlay = $state(false)
$effect(() => {
const stale = slot.loadedPath !== undefined && slot.loadedPath !== path
showOverlay = false
if (!stale) return
const t = setTimeout(() => (showOverlay = true), 150)
return () => clearTimeout(t)
})
</script>
{#snippet loadingOverlay(asOverlay: boolean)}
<div
class="flex items-center justify-center text-secondary {asOverlay
? 'absolute inset-0 z-10 bg-surface/70'
: 'h-full w-full bg-surface'}"
>
<Loader2 size={20} class="animate-spin" />
</div>
{/snippet}
{#if slot.notFound && slot.loadedPath !== path}
<!-- notFound-on-mismatch: the requested target 404'd. Covers first load and a
failed switch (where loadedPath still points at the previous target). -->
<SessionItemNotFound {kind} {path} {onNavigate} />
{:else if slot.loadedPath === undefined}
<!-- First load OR force-reload (loadedPath: B → undefined → B): no editor is
mounted, so the editor's deferred Monaco init can't race a teardown. -->
{@render loadingOverlay(false)}
{:else}
<!-- Breadcrumb swap (A → B): loadedPath stays A here, so editor A remains
visible until B lands; {#key} then remounts to B and its descendants
(e.g. Path.svelte's meta) re-derive from the new target. -->
{#key slot.loadedPath}
{@render editor()}
{/key}
{#if showOverlay}
{@render loadingOverlay(true)}
{/if}
{/if}