Files
windmill/frontend/src/lib/components/sessions/previewReload.test.ts
T
GuilhemandClaude Opus 4.8 32c398f27d feat(sessions): scoped preview refresh + multi-target live editors + pipeline preview (#10006)
* perf(sessions): scope preview-tab refresh to items a chat tool touched

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

* refactor(sessions): drop dead editor pane, scope raw-app reload by path

Multi-target migration P0. SessionWrapper's inline editor pane was dead (the sessions page always mounts it with hideEditor); remove it and the single-target machinery (setSessionTarget/pickEditorTarget/target-keyed editor views). Scope the raw-app file/runnable preview reload to args.path (the app's workspace path) instead of the session target.

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

* refactor(sessions): back editor state with per-(kind,path) cells

Multi-target migration P1. Replace the three per-kind singleton stores/slots with per-(kind,path) cell maps, created on demand and kept (eviction deferred to P3). The runtime's public interface is unchanged: the flowStore/scriptStore/savedScript/rawApp/... getters and slot(kind) now forward to the 'active cell' per kind (a single-target shim, tracked by activePath, removed in P2 when the UI mounts one editor per tab). loadFlow/loadScript/loadRawApp and syncPreviewWithDeployed operate on the resolved cell; load logic and semantics are otherwise unchanged, so loading one item no longer clobbers another's state.

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

* feat(sessions): mount every editable preview tab as its own live editor

Multi-target migration P2 — the behavioral flip. resolvePreviewTab no longer takes a target: any editable route (script/flow/raw_app) resolves to an in-process editor, so several items are live at once (iframes remain only for real pages and regular non-raw apps). Each editor binds its own per-(kind,path) cell; the draft codecs close over that cell's store so two editors never cross-write. The single-target shim (activePath + the flowStore/scriptStore/... getters + slot(kind)) is removed; runtime exposes flowCell/scriptCell/rawAppCell(path). Tab open/navigate dedupe by (kind,path) and no longer setTarget. setLiveEditorDraft is gated on the visible tab (isActiveTab) so N editors don't clobber the one-per-(workspace,kind) live-draft slot (path re-key deferred to P4).

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

* perf(sessions): evict unreferenced editor cells; drop dead warm-editor LRU

Multi-target migration P3. Bound the per-(kind,path) editor cell maps: pruneEditorCells drops every cell no open preview tab still references, wired to a new onTabsChanged adapter callback fired on each tab-set change — so closing or navigating a tab away from an item reclaims its cell (dedupe keeps <=1 editor tab per item, so a pruned item has no live editor to strand). Also remove the now-dead editorWarmIds/promoteEditorWarm/MAX_WARM_EDITORS warm-editor LRU: its only reader (SessionWrapper.mountEditor) was removed in P0, and mounted editors are already capped per-tab by mountedTabKeys.

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

* refactor(sessions): retire session.target; preview is fully tab-driven

Multi-target migration P4 (final). Remove the session.target field and setSessionTarget: the preview is driven entirely by the tab model now (P2). hydratePreviewTabs no longer seeds a tab from target (saved previewTabs only); openEditorInSession seeds the preview via resetSessionPreviewTabs; normalizeLegacySession drops the retired target field from old records. The setLiveEditorDraft focus gate (isActiveTab, one-per-(workspace,kind)) is kept as-is; a per-path re-key is a possible future refinement, not needed for correctness.

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

* docs(sessions): describe editor cells as-is, not by their refactor history

Address standards review: AGENTS.md requires comments describe the code as it is, not its drafting history. Drop the 'used to be per-kind singletons' / 'pre-refactor empty editor' / 'now' phrasings from the cell comments.

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

* docs(sessions): update stale runtime.rawApp.val comments to cell.store

Address spec review: two comments still referenced the removed runtime.rawApp.val accessor; the live code uses the per-cell store now.

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

* docs(sessions): fix editor-cell comments after main merge

Main's #9993 added svelte-ignore comments describing the old
runtime.savedFlow.val / runtime.rawApp.val singleton bindings. The
multi-target refactor binds each tab's own editor cell (cell.store /
cell.saved), so update the comment text to match; the ownership_invalid_binding
directives themselves remain correct (the targets are still runtime-owned).

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

* feat(sessions): restore data-pipeline preview as a live editor tab

The multi-target refactor removed the old single-target editor pane —
PipelineEditorView's only mount point — so open_preview(kind="pipeline")
opened nothing, even though the chat tool and system prompt still make it
the first step of pipeline authoring.

Route a /pipeline/<folder> preview tab to the in-process graph editor:
- previewRouter: parsePipelineRoute + resolvePreviewTab map the folder to a
  pipeline editor slot; PreviewSlot.editorKind gains 'pipeline'.
- previewTargetForSessionTarget('pipeline') returns the folder route target
  (was undefined); open() keeps a single pipeline tab and retargets it to the
  requested folder, since all pipeline tabs share one runtime.pipelineEditorState.
- PreviewTabHost mounts PipelineEditorView for the pipeline slot.
- PipelineEditorView gains an `active` prop; AI-helper registration and the
  live-badge poll now gate on isActiveSession && active.

Register the pipeline tools on the session's own chat, not the singleton:
PreviewTabHost mounts the view outside the SessionWrapper subtree that
provides the scoped aiChatManager context, so getAiChatManager() fell back to
the app-wide singleton — build_pipeline_node / edit_pipeline_node never
reached the session chat and the model fell back to write_script (whose draft
never appears on the canvas). Use runtime.manager directly instead.

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

* fix(sessions): scope list-page preview refresh to the page each tool changes

The scoped-refresh pass reloaded every open list-page preview tab on any
workspace mutation (reloadPages: boolean), so creating a schedule also
refreshed the Resources / Variables tabs.

Replace the blanket flag with the specific page paths each tool can change:
write_schedule → /schedules, write_resource → /resources, write_variable →
/variables, create_folder → /folders, write_trigger → the trigger kind's page;
delete/deploy/discard/rebase map their `type` to its page (none for
script/flow/app). Item-editor writes now reload no pages — their live editor
self-syncs. reloadTabs refreshes a list-page tab only when its own path is in
the touched set.

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

* refactor(sessions): drop the inert item-reload path; extract a tested previewReload module

Post multi-target, every editable item is a live editor whose reload() no-ops,
and the one iframe item kind (legacy drag-drop apps) is never emitted as a
scope — so the whole `scopes` half of the preview-reload machinery could never
fire. Remove it (PreviewKind, PreviewScope, scopeKey, itemTypeToPreviewKind,
pendingScopes, and the item-route branch of reloadTabs); the `pages` path
already covers every real reload.

Lift the surviving pure logic out of the 900-line route component into
previewReload.ts — toolReloadEffect(name,args) -> {pages} and a new
tabsToReload(tabs,pages) mirroring selectPreviewTabsToClose — and cover it with
previewReload.test.ts (per-tool page mapping, item kinds reload nothing, the
unknown/local-tool silent-stale guard, loc-over-url matching).

Also clear session.target leftovers: delete the unread EDITOR_TARGET_KINDS
export and rewrite five comments that still described the removed single-target
pane / target-record write.

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

* docs(sessions): state the preview-reload self-sync invariant once

Consolidate the "live editors self-sync, only list pages reload" rationale
to previewReload.ts and drop the drafting-history phrasings the review
flagged: the update_user_instructions incident and the "(not the runtime)"
contrast in sessionDraftCodecs.

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

* fix(sessions): follow the editor cell when a live tab retargets

Address PR review findings on the multi-target preview.

P1 (Codex) — draft sync stayed bound to the old cell after an in-place tab
retarget. useUserDraftSync captured `codec` once, but navigate() re-points a
live editor tab (script/flow/raw_app) to another item without remounting, so
path/workspace/ready followed the new item while the codec still read/wrote the
previous cell's store — cross-writing drafts. Make `codec` a reactive getter
like the hook's other inputs; SessionEditorTarget rebuilds it per path.

P2 (Claude) — navigate() now enforces the single-pipeline-tab invariant that
open() does: retargeting to a /pipeline/<folder> route focuses and re-points the
existing pipeline tab instead of turning the active tab into a second editor
racing the shared pipelineEditorState.

P2 (Claude) — the deploy-in-session handler peeked an editor slot via the
create-on-miss cell accessors, allocating an empty cell for items with no open
tab. Add a non-creating runtime.loadedEditorPath(kind, path) and use it.

P2 (Claude) — correct a SessionPicker comment left stale by the session.target
removal (the preview no longer seeds from a target).

Tests: two navigate() pipeline-invariant cases. npm run check 0 errors; 167
session unit tests pass.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:34:13 +00:00

88 lines
3.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { toolReloadEffect, tabsToReload } from './previewReload'
import type { SessionPreviewTab } from './sessionState.svelte'
describe('toolReloadEffect', () => {
it('maps a non-item mutation to its own list page only', () => {
expect(toolReloadEffect('write_schedule', { path: 'u/me/s' }).pages).toEqual(['/schedules'])
expect(toolReloadEffect('write_resource', {}).pages).toEqual(['/resources'])
expect(toolReloadEffect('write_variable', {}).pages).toEqual(['/variables'])
expect(toolReloadEffect('create_folder', { name: 'f' }).pages).toEqual(['/folders'])
})
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'])
})
it('maps a generic item tool to the page for its type', () => {
expect(toolReloadEffect('deploy_workspace_item', { type: 'schedule' }).pages).toEqual([
'/schedules'
])
expect(toolReloadEffect('delete_workspace_item', { type: 'resource' }).pages).toEqual([
'/resources'
])
expect(
toolReloadEffect('discard_local_draft', { type: 'trigger', trigger_kind: 'nats' }).pages
).toEqual(['/nats_triggers'])
})
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([])
}
for (const name of [
'write_script',
'edit_script',
'write_flow',
'init_app',
'write_app_file'
]) {
expect(toolReloadEffect(name, { path: 'u/me/x' }).pages).toEqual([])
}
})
it('reloads nothing for a purely local or unknown tool (the silent-stale guard)', () => {
expect(toolReloadEffect('update_user_instructions', {}).pages).toEqual([])
expect(toolReloadEffect('some_future_tool', { path: 'p' }).pages).toEqual([])
})
it('reloads nothing for a trigger of unknown kind rather than guessing', () => {
expect(toolReloadEffect('write_trigger', { kind: 'not_a_kind' }).pages).toEqual([])
})
})
describe('tabsToReload', () => {
const scheduleTab: SessionPreviewTab = { id: 's', url: '/schedules', loc: '/schedules' }
const resourceTab: SessionPreviewTab = { id: 'r', url: '/resources', loc: '/resources' }
const scriptTab: SessionPreviewTab = {
id: 'sc',
url: '/scripts/edit/f/foo/bar',
loc: '/scripts/edit/f/foo/bar'
}
const pipelineTab: SessionPreviewTab = { id: 'p', url: '/pipeline/crm', loc: '/pipeline/crm' }
const tabs = [scheduleTab, resourceTab, scriptTab, pipelineTab]
it('returns only the tabs whose page is in the set', () => {
expect(tabsToReload(tabs, new Set(['/schedules']))).toEqual([scheduleTab])
})
it('returns list-page tabs but never item-editor or pipeline tabs', () => {
// toolReloadEffect only ever emits list-page paths, so item/pipeline route
// paths are never in `pages` — those tabs self-sync and stay put.
expect(tabsToReload(tabs, new Set(['/schedules', '/resources']))).toEqual([
scheduleTab,
resourceTab
])
})
it('is empty when no pages were touched', () => {
expect(tabsToReload(tabs, new Set())).toEqual([])
})
it('matches on the observed loc (with query/hash stripped) over the seeded url', () => {
const navigated: SessionPreviewTab = { id: 'n', url: '/runs', loc: '/schedules?workspace=w' }
expect(tabsToReload([navigated], new Set(['/schedules']))).toEqual([navigated])
})
})