fix(script-editor,raw-app-editor): preserve AI chat across editor nav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-06-01 14:23:22 +02:00
parent 17697b0fba
commit 0e869b6e18
2 changed files with 68 additions and 10 deletions
@@ -28,6 +28,7 @@
import JobLoader from './JobLoader.svelte'
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte'
import { beforeNavigate } from '$app/navigation'
import { Button } from './common'
import SplitPanesWrapper from './splitPanes/SplitPanesWrapper.svelte'
import WindmillIcon from './icons/WindmillIcon.svelte'
@@ -1203,6 +1204,24 @@
}
})
// Preserve the chat across the /scripts/add → /scripts/edit/{path} promotion
// (and same-script reloads): ScriptEditor unmounts + remounts on those
// transitions, and without this its onDestroy would saveAndClear the
// SCRIPT-mode conversation the user is still actively having about this same
// script.
let preserveChatOnDestroy = $state(false)
beforeNavigate(({ to }) => {
const dest = to?.url.pathname ?? ''
if (!dest.startsWith('/scripts/edit/')) return
const destPath = dest.slice('/scripts/edit/'.length)
const currentPath = aiChatManager.scriptEditorOptions?.path
// !currentPath: on /scripts/add, dest is /scripts/edit/{path} (initial save).
// destPath === currentPath: same script (e.g. query change / reload).
if (!currentPath || destPath === currentPath) {
preserveChatOnDestroy = true
}
})
onMount(async () => {
await inferSchema(code, { applyInitialArgs: true })
// Retry once if the initial inference failed silently (e.g. transient WASM
@@ -1211,7 +1230,12 @@
if (!validCode && code && lang) {
await inferSchema(code, { applyInitialArgs: true })
}
aiChatManager.saveAndClear()
// The previous instance's onDestroy may have preserved the chat for
// intra-script-editor nav; in that case mode is still SCRIPT and the
// conversation is intact. Skip saveAndClear so we don't blow it away.
if (aiChatManager.mode !== AIMode.SCRIPT) {
aiChatManager.saveAndClear()
}
aiChatManager.changeMode(AIMode.SCRIPT)
})
@@ -1298,8 +1322,10 @@
aiChatManager.scriptEditorShowDiffMode = undefined
aiChatManager.scriptEditorGetLintErrors = undefined
aiChatManager.scriptEditorOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
if (!preserveChatOnDestroy) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
// Clean up debug mode
if (debugMode) {
stopDebugging()
@@ -1357,9 +1383,7 @@
// width (Svelte wires a ResizeObserver for bind:clientWidth).
let splitContainerWidth = $state(0)
const TEST_PANE_MIN_PX = 400
const testPaneMinPercent = $derived(
paneMinPercent(splitContainerWidth, TEST_PANE_MIN_PX)
)
const testPaneMinPercent = $derived(paneMinPercent(splitContainerWidth, TEST_PANE_MIN_PX))
// Raw user-controlled test size (what the splitter wrote, or what the
// toggle set). The size we actually pass to <Pane> is clamped to the
@@ -21,7 +21,8 @@
import type { Modules } from './RawAppModules.svelte'
import { isRunnableByName, isRunnableByPath } from '../apps/inputType'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import { onMount, untrack } from 'svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import { beforeNavigate } from '$app/navigation'
import type {
AppDatatableMetadata,
LintResult,
@@ -618,8 +619,30 @@
)
}
// Preserve the chat across the /apps_raw/add → /apps_raw/edit/{path} promotion
// (and same-app reloads): RawAppEditor unmounts + remounts on those
// transitions, and without this the fresh onMount's saveAndClear would blow
// away the APP-mode conversation the user is still actively having about this
// same app.
let preserveChatOnDestroy = $state(false)
beforeNavigate(({ to }) => {
const dest = to?.url.pathname ?? ''
if (!dest.startsWith('/apps_raw/edit/')) return
const destPath = dest.slice('/apps_raw/edit/'.length)
// !path: on /apps_raw/add, dest is /apps_raw/edit/{path} (initial deploy).
// destPath === path: same app.
if (!path || destPath === path) {
preserveChatOnDestroy = true
}
})
onMount(() => {
aiChatManager.saveAndClear()
// The previous instance may have preserved the chat for intra-app-editor
// nav; in that case mode is still APP and the conversation is intact. Skip
// saveAndClear so we don't blow it away.
if (aiChatManager.mode !== AIMode.APP) {
aiChatManager.saveAndClear()
}
aiChatManager.changeMode(AIMode.APP)
rawAppLintStore.enable()
loadSharedUi()
@@ -645,6 +668,16 @@
}
})
onDestroy(() => {
// Cross-app / leave-editor navigation resets to NAVIGATOR; same-app /
// add→edit navigation keeps the chat (the fresh onMount detects mode is
// still APP and skips its clearing saveAndClear).
if (!preserveChatOnDestroy) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
})
// Sync data with aiChatManager.datatableCreationPolicy (bidirectional)
$effect(() => {
// Read the current policy from aiChatManager
@@ -1637,8 +1670,9 @@
title="Build failed"
class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']"
>
<pre
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
>{buildError}</pre
>
</Alert>
</div>
{/if}