diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index b6a5c6611f..a3ca1a6e49 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -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 { + 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(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/) opens the flow exactly as it is in the - // editor right now. - beforeOpen: saveDraft + beforeOpen: persistDraftForSession } : undefined} onOpenPreview={flowPreviewButtons?.openPreview} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index aa68a2bfc1..9b55b86165 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -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 { + 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 @@ { + 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} > diff --git a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte index ce1fd38221..b0a7778df5 100644 --- a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte +++ b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte @@ -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_` 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 diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 747ecda6b7..51bfc1bc8f 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -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() }) }) diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 9bc46fce0f..e9aec2637e 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -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_` - * 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_` 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 } diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index eba09d6dd0..67d411b306 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -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. diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index 4de758fe94..436b909e21 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -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', () => { diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 220daf4502..79af616918 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -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_` (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 diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 8437a1b87c..1ffdf3a2e4 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -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 { + 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. diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index df5963dd6a..4baa06c819 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -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( - (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_`), - // 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_` 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)}