fix(frontend): open new script/flow/app in AI session (not-found + friendly tab) (#10028)

* fix(frontend): open new script/flow/app in AI session without "not found"

"Open in AI session" on a never-deployed item opened the session preview
against the friendly live-edited path (script.path / $pathStore) instead of
the URL draft path the editor loads and saves by, so get-by-path 404'd. It
also flushed only queued autosaves, so an untouched new item — which never
triggered autosave — had no draft row to load at all.

Target the URL draft path (userDraftPath / liveEditorDraftStoragePath;
raw-app already used appPath), and add UserDraft.forcePersist to materialize
a brand-new draft in beforeOpen, gated to never-deployed items where there is
no deployed baseline to discard against.

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

* fix(frontend): label a new session preview tab by its friendly name

A never-deployed item's preview tab read draft_<uuid> instead of the typed/
auto name. The sessions page can't reactively read a runtime cell's state
across reactive roots, so the live editor (SessionEditorTarget, handed the
runtime as a prop) now stamps a transient friendlyLabel onto the tab model —
which the page does observe — via a pure draftFriendlyLeaf helper. Unifies
scripts, flows and raw apps through one path.

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

* fix(frontend): address review nits on AI-session open

- Flow drawer (FlowEditorDrawer) mounts FlowBuilder with no
  liveEditorDraftStoragePath, so gating the AI button solely on it hid the
  session entry point there; fall back to $pathStore (the pre-PR behavior for
  those deployed-flow drawers) while the main editor still prefers the URL
  draft path.
- Clear a tab's stamped friendlyLabel when it is retargeted, so a draft tab's
  friendly name no longer lingers after navigating to a plain page.
- Trim the repeated persist-hook comments to satisfy the AGENTS.md comment rule.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2026-07-10 13:39:52 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 Ruben Fiszel
parent 6c521e9d87
commit 03535691d6
11 changed files with 173 additions and 85 deletions
+19 -6
View File
@@ -305,6 +305,17 @@
})
}
// Materialize a brand-new flow's draft before the session preview loads it by
// path — an untouched new flow never autosaved, so forcePersist is the only
// thing that creates the row. Gated to never-deployed: forcePersist skips the
// discardIf baseline, safe only when there is none.
async function persistDraftForSession(): Promise<void> {
await saveDraft()
if (opWorkspace && liveEditorDraftStoragePath && newFlow) {
await UserDraft.forcePersist('flow', liveEditorDraftStoragePath, { workspace: opWorkspace })
}
}
export function computeUnlockedSteps(flow: Flow) {
return Object.fromEntries(
getAllModules(flow.value.modules, flow.value.failure_module)
@@ -512,6 +523,11 @@
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
// "Open in AI session" target: the URL draft path the editor loads/saves by
// (which for a new flow differs from the live-edited friendly `$pathStore`),
// falling back to `$pathStore` in drawer mounts that carry no storage path.
const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore)
$effect(() => {
if (liveEditorDraftStoragePath === undefined || !opWorkspace) return
const workspace = opWorkspace
@@ -1257,14 +1273,11 @@
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
toggleAiChat={() => aiChatManager.toggleOpen()}
sessionOpen={$pathStore
sessionOpen={sessionTargetPath
? {
target: { kind: 'flow', path: $pathStore },
target: { kind: 'flow', path: sessionTargetPath },
workspaceId: opWorkspace ?? undefined,
// Persist unsaved edits so the session preview
// (/flows/edit/<path>) opens the flow exactly as it is in the
// editor right now.
beforeOpen: saveDraft
beforeOpen: persistDraftForSession
}
: undefined}
onOpenPreview={flowPreviewButtons?.openPreview}
@@ -756,6 +756,17 @@
})
}
// Materialize a brand-new script's draft before the session preview loads it by
// path — an untouched new script never autosaved, so forcePersist is the only
// thing that creates the row. Gated to never-deployed: forcePersist skips the
// discardIf baseline, safe only when there is none.
async function persistDraftForSession(): Promise<void> {
await saveDraft()
if (opWorkspace && userDraftPath && savedScript?.no_deployed === true) {
await UserDraft.forcePersist('script', userDraftPath, { workspace: opWorkspace })
}
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
@@ -2098,13 +2109,13 @@
<ScriptEditor
{disableAi}
workspaceOverride={opWorkspace}
sessionOpen={script.path
sessionOpen={userDraftPath
? {
target: { kind: 'script', path: script.path },
// URL draft path the editor loads/saves by, not the friendly
// `script.path` (a new script's has no row → "not found").
target: { kind: 'script', path: userDraftPath },
workspaceId: opWorkspace ?? undefined,
// Flush the per-user draft so the session preview opens the script
// exactly as it is in the editor right now.
beforeOpen: saveDraft
beforeOpen: persistDraftForSession
}
: undefined}
bind:selectedTab={selectedInputTab}
@@ -223,6 +223,22 @@
const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
// Materialize a brand-new app's draft before the session preview loads it by
// path — an untouched new app never autosaved, so forcePersist is the only
// thing that creates the row (`appPath === indicatorPath` in the full-page
// editor). Gated to never-deployed: forcePersist skips the discardIf baseline.
async function persistDraftForSession(): Promise<void> {
if (!opWorkspace || indicatorPath === undefined) return
await UserDraftDbSyncer.flush({
workspace: opWorkspace,
itemKind: 'raw_app',
path: indicatorPath
})
if (newApp) {
await UserDraft.forcePersist('raw_app', indicatorPath, { workspace: opWorkspace })
}
}
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
@@ -898,16 +914,9 @@
? {
target: { kind: 'raw_app', path: appPath },
workspaceId: opWorkspace ?? undefined,
// Flush the autosaved draft so the session preview opens the app
// exactly as it is in the editor right now.
beforeOpen: () =>
opWorkspace && indicatorPath !== undefined
? UserDraftDbSyncer.flush({
workspace: opWorkspace,
itemKind: 'raw_app',
path: indicatorPath
})
: undefined
// Persist the draft (and materialize a brand-new one) so the session
// preview opens the app exactly as it is in the editor right now.
beforeOpen: persistDraftForSession
}
: undefined}
>
@@ -6,6 +6,7 @@
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 {
@@ -106,6 +107,18 @@
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
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parsePreviewItemRoute, previewTabLabel, resolvePreviewTab } from './previewRouter'
import { draftFriendlyLeaf, parsePreviewItemRoute, resolvePreviewTab } from './previewRouter'
describe('parsePreviewItemRoute', () => {
it('maps edit/get routes to item kinds', () => {
@@ -32,35 +32,24 @@ describe('parsePreviewItemRoute', () => {
})
})
describe('previewTabLabel', () => {
it('labels a new raw app by its pending friendly path, not the draft uuid', () => {
const rawApp = { path: 'u/admin/draft_abc123', draft_path: 'u/admin/my_pretty_app' }
expect(previewTabLabel('/apps_raw/edit/u/admin/draft_abc123', rawApp)).toBe('my_pretty_app')
describe('draftFriendlyLeaf', () => {
it('returns the friendly leaf for a new item parked at a draft uuid', () => {
expect(draftFriendlyLeaf('u/admin/draft_abc123', 'u/admin/valuable_script')).toBe(
'valuable_script'
)
expect(draftFriendlyLeaf('u/admin/draft_abc123', 'u/admin/my_flow')).toBe('my_flow')
})
it('falls back to the uuid leaf when no friendly draft_path is pending', () => {
const rawApp = { path: 'u/admin/draft_abc123' }
expect(previewTabLabel('/apps_raw/edit/u/admin/draft_abc123', rawApp)).toBe('draft_abc123')
it('returns undefined when no friendly path is available', () => {
expect(draftFriendlyLeaf('u/admin/draft_abc123', undefined)).toBeUndefined()
})
it('keeps the real leaf for a raw app already at a named (non-draft) path', () => {
const rawApp = { path: 'u/admin/my_app', draft_path: 'u/admin/renamed' }
expect(previewTabLabel('/apps_raw/edit/u/admin/my_app', rawApp)).toBe('my_app')
it('returns undefined when the friendly path is itself a draft placeholder', () => {
expect(draftFriendlyLeaf('u/admin/draft_abc123', 'u/admin/draft_xyz')).toBeUndefined()
})
it('ignores a draft_path that belongs to a different raw app than the tab shows', () => {
const rawApp = { path: 'u/admin/draft_other', draft_path: 'u/admin/friendly' }
expect(previewTabLabel('/apps_raw/edit/u/admin/draft_abc123', rawApp)).toBe('draft_abc123')
})
it('does not touch non-raw-app tabs', () => {
const rawApp = { path: 'u/admin/draft_abc123', draft_path: 'u/admin/friendly' }
expect(previewTabLabel('/scripts/edit/f/foo/bar', rawApp)).toBe('bar')
expect(previewTabLabel('/runs', rawApp)).toBe('Runs')
})
it('falls back to the plain location label when no raw app is loaded', () => {
expect(previewTabLabel('/apps_raw/edit/u/admin/draft_abc123', undefined)).toBe('draft_abc123')
it('returns undefined for an item already at a named (non-draft) storage path', () => {
expect(draftFriendlyLeaf('u/admin/my_app', 'u/admin/renamed')).toBeUndefined()
})
})
@@ -110,27 +110,20 @@ export function previewLocationLabel(url: string): string {
return stripBase(url)
}
/** Tab label for a preview location, preferring the friendly path a raw-app
* editor was renamed to while still parked at its throwaway `…/draft_<uuid>`
* storage path. `rawAppDraft` is the session's live raw app (its storage `path`
* plus the pending `draft_path` the user typed in the editor). When the tab
* shows that app at a draft placeholder path, it's labelled by the friendly
* leaf rather than the uuid. Display-only — the tab's URL keeps the storage
* path. Falls back to `previewLocationLabel` for every other tab. */
export function previewTabLabel(
url: string,
rawAppDraft?: { path: string; draft_path?: string }
): string {
const route = parsePreviewItemRoute(url)
if (
route?.raw_app &&
rawAppDraft?.draft_path &&
rawAppDraft.path === route.itemPath &&
route.itemPath.split('/').pop()?.startsWith('draft_')
) {
return rawAppDraft.draft_path.split('/').pop() ?? rawAppDraft.draft_path
}
return previewLocationLabel(url)
/** The friendly display leaf for a preview tab, or `undefined` to fall back to
* `previewLocationLabel`. A never-deployed script / flow / raw app is parked at a
* throwaway `…/draft_<uuid>` storage path while its editor shows a friendly name
* (auto-generated or typed); pass that `friendlyPath` — the live cell's
* `draft_path`/`path` — to label the tab by its leaf instead of the uuid. Returns
* `undefined` for a deployed item (real storage path) or when the friendly path
* is itself a placeholder. Display-only: the tab's URL keeps the storage path. */
export function draftFriendlyLeaf(
storagePath: string,
friendlyPath: string | undefined
): string | undefined {
if (!storagePath.split('/').pop()?.startsWith('draft_')) return undefined
const leaf = friendlyPath?.split('/').pop()
return leaf && !leaf.startsWith('draft_') ? leaf : undefined
}
export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string }
@@ -48,6 +48,15 @@ function targetUrl(target: PreviewTarget): string {
return target.type === 'page' ? target.href : `${base}${editPathFor(target.item)}`
}
// Point a tab at a new destination. Clears `friendlyLabel` (bound to the previous
// editor's item): a new editor re-stamps it, and navigating to a plain page must
// drop the stale name so the tab falls back to the location label.
function retargetTab(tab: SessionPreviewTab, url: string): void {
tab.url = url
tab.loc = url
tab.friendlyLabel = undefined
}
// Strip the query params the sessions preview injects into iframe URLs
// (`nomenubar` to hide the nav, `workspace` to scope the page): they aren't part
// of the canonical page URL. The observed `loc` must drop them to stay symmetric
@@ -211,8 +220,7 @@ export class SessionPreviewTabs {
const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) !== null)
if (existing) {
const same = existing.url === url
existing.url = url
existing.loc = url
retargetTab(existing, url)
this.#activeId = existing.id
this.#flush()
return { status: same ? 'focused' : 'opened' }
@@ -260,15 +268,13 @@ export class SessionPreviewTabs {
if (pipelineFolder) {
const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) !== null)
if (existing && existing.id !== t.id) {
existing.url = url
existing.loc = url
retargetTab(existing, url)
this.#activeId = existing.id
this.#flush()
return
}
}
t.url = url
t.loc = url
retargetTab(t, url)
this.#flush()
}
@@ -335,6 +341,17 @@ export class SessionPreviewTabs {
this.#flush()
}
// Stamp the friendly display label for the editor tab hosting `target` (the
// live editor knows the item's typed/auto name once its cell loads, which the
// page can't read reactively from the runtime cell). Matched on the tab's
// commanded `url` — the stable per-(kind,path) editor identity. Transient, so
// no persist/flush: it's recomputed when the tab remounts.
setEditorFriendlyLabel(target: SessionTarget, label: string | undefined): void {
const t = this.#tabs.find((x) => isEditorTabFor(x.url, target))
if (!t || t.friendlyLabel === label) return
t.friendlyLabel = label
}
// Persist a pending write immediately, cancelling the debounce. Called on
// page hide — a mutation inside the debounce window would otherwise be lost
// to a reload/navigation. No-op when nothing is pending.
@@ -290,6 +290,16 @@ describe('SessionPreviewTabs.navigate', () => {
expect(o.activeId).toBe(tabId)
expect(o.tabs[0].url).toBe(`${base}/pipeline/sales`)
})
it('drops a stale friendly label when the tab is retargeted', () => {
const o = owner()
o.open(flowTarget)
o.setEditorFriendlyLabel({ kind: 'flow', path: 'u/me/bar' }, 'luminous_flow')
expect(o.tabs[0].friendlyLabel).toBe('luminous_flow')
// Navigating the same tab to a plain page must clear the flow's name.
o.navigate(pageTarget)
expect(o.tabs[0].friendlyLabel).toBeUndefined()
})
})
describe('SessionPreviewTabs.select / close / setCollapsed', () => {
@@ -124,7 +124,10 @@ export type Session = {
// One preview tab: `url` is the URL we command the iframe to load, `loc` the
// last observed location (see the sessions page for the url/loc split).
export type SessionPreviewTab = { id: string; url: string; loc: string }
// `friendlyLabel` is a transient display override the live editor stamps for a
// never-deployed item parked at `…/draft_<uuid>` (its typed/auto name); not
// persisted (hydrate rebuilds tabs field-by-field), recomputed on next mount.
export type SessionPreviewTab = { id: string; url: string; loc: string; friendlyLabel?: string }
// Sessions live in one per-user IndexedDB, one record per session in the
// `sessions` store keyed by `id`. IndexedDB is the sole store — no localStorage
+32
View File
@@ -259,6 +259,38 @@ export const UserDraft = {
}
},
/**
* Persist this key's CURRENT in-memory value to the server immediately,
* bypassing autosave's change-detection. For "Open in AI session" on a
* never-deployed item: it may have no draft row yet (an untouched new
* script's template seed never triggers autosave), and the session preview
* loads it via get-by-path, which 404s without a persisted draft. No-op
* when no value is held for this key. Resolves once the POST lands.
*
* MUST NOT be a general save path: it skips the handle's `discardIf`
* baseline check, so a value back at the deployed baseline would persist a
* no-op draft instead of the delete autosave sends. Only safe for
* never-deployed items, which have no baseline.
*/
async forcePersist(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): Promise<void> {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
const val = entry ? entry.state.val : writtenCache.get(mk)?.val
if (val === undefined) return
await UserDraftDbSyncer.save({
workspace: ws,
itemKind,
path,
value: snapshotDraftValue(val),
immediate: true
})
},
/**
* Current draft value from the in-memory cell. `undefined` when no
* editor has mounted a handle for this key in this tab.
@@ -39,8 +39,7 @@
import {
getOrCreateRuntime,
getRuntime,
listRuntimes,
type SessionRuntime
listRuntimes
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
@@ -50,7 +49,7 @@
matchPreviewPage,
pageKey,
parsePreviewItemRoute,
previewTabLabel,
previewLocationLabel,
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
@@ -249,7 +248,7 @@
// Adapt the session tab model to DraggableTabs items (labels derived from the
// observed location; every tab closable, none pinned).
const previewTabItems = $derived<TabItem[]>(
(owner?.tabs ?? []).map((t) => ({ id: t.id, label: tabLabelFor(activeRuntime, t.loc) }))
(owner?.tabs ?? []).map((t) => ({ id: t.id, label: tabLabelFor(t) }))
)
let newTabOpen = $state(false)
// Separate open flag for the empty-state launcher: it can be mounted at the
@@ -467,13 +466,12 @@
owner?.navigate(target)
}
// Short tab label. For a raw-app tab, feed its own per-path cell so the tab is
// labelled by that app's pending draft path (a rename parked at `draft_<uuid>`),
// scoped to the tab's own runtime rather than another session's.
function tabLabelFor(rt: SessionRuntime | undefined, url: string): string {
const route = parsePreviewItemRoute(url)
const rawAppDraft = rt && route?.raw_app ? rt.rawAppCell(route.itemPath).store.val : undefined
return previewTabLabel(url, rawAppDraft)
// Short tab label. A never-deployed item parked at `…/draft_<uuid>` carries a
// `friendlyLabel` its live editor stamped (the page can't read the runtime cell
// reactively; the editor mirrors the typed/auto name onto the tab model). Falls
// back to the plain location label for deployed items and non-item pages.
function tabLabelFor(tab: SessionPreviewTab): string {
return tab.friendlyLabel ?? previewLocationLabel(tab.loc)
}
// A link click inside a live editor (e.g. a subflow reference) re-points the
@@ -761,7 +759,7 @@
runtime={rt}
active={s.id === activeSession?.id && tab.id === tabs?.activeId}
mounted={mountedTabKeys.has(tabKey(s.id, tab.id))}
label={tabLabelFor(rt, tab.loc)}
label={tabLabelFor(tab)}
darkMode={isDarkMode.val}
onNavigate={navigateEditorTo}
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}