fix(frontend): stop spurious asset analysis toasts in the flow editor (#10349)

* fix(frontend): stop spurious asset analysis toasts in the flow editor

The flow editor asks "Assets were detected in this step. Analyze entire
flow for assets?" whenever a raw script step without asset metadata is
selected and its code turns out to declare assets. Nothing recorded that
the question had already been asked, and the selection watcher is
re-created (and fires) on every structural change to the flow, so the
prompt reappeared on every step click and every time a step was added.
Steps created during the session — most visibly the ones an AI agent
inserts one by one — were also treated as legacy steps, so each new step
raised its own prompt even though writing their assets only completes an
edit the user already made.

Ask at most once per editor session, restrict the prompt to the modules
the flow was loaded with, and skip re-analyzing a module whose content
has not changed since its last parse.

Fixes WIN-2251

* fix(frontend): key the asset inference cache on language and replay it

inferAssets depends on the module's language as well as its content, and
the content-only cache also turned a re-derivation into a no-op whenever
the assets field alone was reset (undo/redo, reset to deployed, AI diff
apply). Cache the inference result keyed on both inputs and re-apply it
on a hit, so a cache hit is idempotent rather than a skip; that also
removes the need for analyzeEntireFlow to force a re-parse.

Cached values are copied before reaching the flow store, which would
otherwise proxy them and let a later replay mutate the cache in place.

Accepting "Analyze entire flow" now carries over to modules analyzed
later in the session instead of leaving them for a prompt that will not
be shown again.

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

* fix(frontend): build the asset cache key without a raw NUL byte

The separator was written as a literal U+0000, which makes git treat the
Svelte source as binary: diffs render as +0/-0, blame and log -p stop
working, and ripgrep skips the file. Build the key with JSON.stringify
instead, which is unambiguous and keeps the file ASCII.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-27 12:20:21 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 4b7ab64a48
commit 9bbfe12011
@@ -117,10 +117,32 @@
})
}
})
// Prune all additionalAssetsMap entries from deleted modules
// Ids the flow was loaded with. Only those modules can carry asset metadata predating
// the assets feature; anything appearing later was added in this session, so writing
// its assets adds nothing on top of a change the user has already made. The editor
// only mounts once the flow is loaded, so reading the prop here is the loaded state.
const loadedModuleIds = new Set(getAllModules(modules).map((m) => m.id))
// Analyzing is a flow-wide action: offer it once, then apply that answer to every
// other module of the flow rather than asking again for each one. A prompt left
// unanswered stays 'offered' and keeps those modules untouched for the session.
let flowAnalysis: 'unoffered' | 'offered' | 'accepted' = 'unoffered'
// Last inference per module, keyed on everything inferAssets depends on. The watchers
// below are re-created whenever a module is added or removed and the selection watch
// fires on creation, so a miss here means a re-parse on every structural edit.
type InferredAssets = Extract<Awaited<ReturnType<typeof inferAssets>>, { status: 'ok' }>
let analyzed: Record<string, { key: string; result: InferredAssets }> = {}
// Prune per-module caches from deleted modules
$effect(() => {
if (!flowGraphAssetsCtx) return
const modulesSet = new Set(allModules.map((m) => m.id))
for (const key of Object.keys(analyzed)) {
if (!modulesSet.has(key)) delete analyzed[key]
}
for (const key of [...loadedModuleIds]) {
if (!modulesSet.has(key)) loadedModuleIds.delete(key)
}
if (!flowGraphAssetsCtx) return
for (const key of Object.keys(flowGraphAssetsCtx.val.additionalAssetsMap)) {
if (!modulesSet.has(key)) {
delete flowGraphAssetsCtx.val.additionalAssetsMap[key]
@@ -129,6 +151,7 @@
})
function analyzeEntireFlow() {
flowAnalysis = 'accepted'
for (const mod of allModules) {
if (mod.value.type === 'rawscript') {
parseAndUpdateRawScriptModule(mod.value, mod.id)
@@ -136,27 +159,37 @@
}
}
async function parseAndUpdateRawScriptModule(
v: RawScript,
modId: string,
isUserEdit: boolean = true
) {
console.log('Parsing assets for RawScript module', modId)
let inferAssetsResult = await inferAssets(v.language, v.content)
if (inferAssetsResult.status === 'error') return
if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = inferAssetsResult.sql_queries
let newAssets = inferAssetsResult.assets as AssetWithAltAccessType[]
async function parseAndUpdateRawScriptModule(v: RawScript, modId: string, prompt = false) {
const key = JSON.stringify([v.language, v.content])
let inferred = analyzed[modId]?.key === key ? analyzed[modId].result : undefined
if (!inferred) {
const inferAssetsResult = await inferAssets(v.language, v.content)
if (inferAssetsResult.status === 'error') return
inferred = inferAssetsResult
analyzed[modId] = { key, result: inferred }
}
// Copy before handing anything to the flow store: stored values become reactive
// proxies, and a later replay of this same inference would mutate the cache.
const { assets, sql_queries } = structuredClone(inferred)
if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = sql_queries
let newAssets = assets as AssetWithAltAccessType[]
for (const asset of newAssets) {
const old = v.assets?.find((a) => assetEq(a, asset))
if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type
}
const normalizedAssets = newAssets.length > 0 ? newAssets : undefined
if (!deepEqual(v.assets, normalizedAssets)) {
if (!isUserEdit && normalizedAssets && normalizedAssets.length > 0) {
if (prompt && flowAnalysis !== 'accepted' && normalizedAssets?.length) {
if (flowAnalysis === 'offered') return
flowAnalysis = 'offered'
// Long-lived because it is the only entry point to analyzeEntireFlow and it is
// offered once: a toast the user misses cannot be brought back without a reload.
sendUserToast(
'Assets were detected in this step. Analyze entire flow for assets?',
'warning',
[{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }]
[{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }],
undefined,
20000
)
} else {
v.assets = normalizedAssets
@@ -181,7 +214,11 @@
// Also recompute if the module is selected
watch([() => selectedId === mod.id], () => {
if (selectedId === mod.id)
parseAndUpdateRawScriptModule(modValue, mod.id, modValue.assets !== undefined)
parseAndUpdateRawScriptModule(
modValue,
mod.id,
modValue.assets === undefined && loadedModuleIds.has(mod.id)
)
})
}
}