fix: route legacy AI entry points to sessions instead of the unmounted chat (#10705)

* fix: route legacy AI entry points to sessions instead of the unmounted chat

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

* fix: keep createSession's workspace choice and revert pipeline hand-off

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

* fix: guard in-session step generation and restore AI action labels

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

* fix: keep AI Fix usable in-session and stop silent no-op hand-offs

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

* fix: neutral AI form assistant heading to match both branches

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

* docs: state the AI form assistant branch rationale once

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

* feat: auto-send AI hand-offs and keep in-session step generation in global mode

* fix: name the AI session in the entry point labels

* fix: claim auto-send reactively and queue programmatic sends mid-turn

* test: pin the auto-send claim going stale

* fix: stop the script drawer hand-off from abandoning its unsaved script

* fix: keep a stale hand-off prompt and close the pre-loading send window

* fix: only blank the composer for an intent this wrapper can claim

* fix: report composer edits only, never the mount-time draft

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-08-18 17:06:51 +02:00
committed by GitHub
parent 8efede55d6
commit 494e6f146e
24 changed files with 757 additions and 109 deletions
+17 -5
View File
@@ -10,7 +10,7 @@
import { buildWsUrl } from '$lib/wsUrl'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
import { createEventDispatcher, getContext, onDestroy, onMount, tick, untrack } from 'svelte'
// import libStdContent from '$lib/es6.d.ts.txt?raw'
// import domContent from '$lib/dom.d.ts.txt?raw'
@@ -110,7 +110,8 @@
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
import { editorPositionMap } from '$lib/utils'
import { extToLang, langToExt } from '$lib/editorLangUtils'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import { aiChatManager, type AIChatManager } from './copilot/chat/AIChatManager.svelte'
import { chatState } from './copilot/chat/sharedChatState.svelte'
import type { Selection } from 'monaco-editor'
import { canHavePreprocessor, getPreprocessorModuleCode } from '$lib/script_helpers'
import { setMonacoTypescriptOptions } from './monacoLanguagesOptions'
@@ -264,6 +265,11 @@
let disposeMethod: (() => void) | undefined
const absolutePathExtraLibs = new Map<string, { dispose: () => void }>()
const dispatch = createEventDispatcher()
// Set by the sessions pane; undefined everywhere else. ⌘L targets it so the
// shortcut reaches the chat the user is actually looking at. Read directly
// rather than via getAiChatManager(), which collapses "no session" into the
// singleton — the distinction is what tells ⌘L whether a pane exists to open.
const sessionScopedChatManager = getContext<AIChatManager | undefined>('aiChatManager')
// let graphqlService: MonacoGraphQLAPI | undefined = undefined
let dbSchema: DBSchema | undefined = $state(undefined)
@@ -1695,16 +1701,22 @@
selection &&
(selection.startLineNumber !== selection.endLineNumber ||
selection.startColumn !== selection.endColumn)
// Target whichever chat is actually on screen: the session's own in a
// session pane, else the docked one. With sessions on outside a pane
// there is neither, and both branches below would be silent no-ops.
const chat = sessionScopedChatManager ?? aiChatManager
if (!sessionScopedChatManager && !chatState.dockedChatAvailable) return
if (hasSelection && selectedLines) {
aiChatManager.addSelectedLinesToContext(
chat.addSelectedLinesToContext(
selectedLines,
selection.startLineNumber,
selection.endLineNumber,
moduleId
)
} else {
aiChatManager.toggleOpen()
aiChatManager.focusInput()
// A session chat is always visible — only the docked pane toggles.
if (!sessionScopedChatManager) chat.toggleOpen()
chat.focusInput()
}
})
@@ -85,8 +85,12 @@
bind:this={outputPickerInner}
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && stepsInputArgs.getStepArgs(mod.id) && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)}
<ScriptFix {lang} />
{@const stepError =
selectedJob?.type === 'CompletedJob' && !selectedJob.success
? getStringError(selectedJob.result)
: undefined}
{#if lang && editor && diffEditor && stepsInputArgs.getStepArgs(mod.id) && stepError}
<ScriptFix {lang} error={stepError} jobId={selectedJob?.id} moduleId={mod.id} />
{/if}
{/snippet}
</OutputPickerInner>
@@ -95,6 +95,7 @@
import OpenInSessionButton, {
type OpenInSessionSource
} from './sessions/OpenInSessionButton.svelte'
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
// Forward-looking hook for the upcoming session-pane feature: that PR will
// `setContext('aiChatManager', ...)` from the session wrapper so this editor
@@ -278,6 +279,14 @@
let opWs = $derived(workspaceOverride ?? $workspaceStore)
// Publish this editor's hand-off for AI entry points below it (the preview
// panel's "AI Fix"), withheld under `disableAi` so an embed that turned AI off
// gets no entry point that navigates its host to /sessions. Shadows an
// ancestor's hand-off deliberately: ScriptEditorDrawer mounts this without a
// `sessionOpen`, and falling through to FlowBuilder's would answer "fix this
// script" by opening the flow and abandoning the drawer's unsaved content.
setOpenInSessionHandoff({ source: () => (disableAi ? undefined : sessionOpen) })
$effect(() => {
onTestStateChange?.(testIsLoading)
})
@@ -1,27 +1,67 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Pencil } from 'lucide-svelte'
import { Pencil, WandSparkles } from 'lucide-svelte'
import { aiChatManager } from './chat/AIChatManager.svelte'
import AskAiButton from './AskAiButton.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
import { workspaceStore } from '$lib/stores'
interface Props {
onEditInstructions: () => void
instructions: string
runnableType: 'script' | 'flow'
path: string | undefined
}
const { onEditInstructions, instructions, runnableType }: Props = $props()
const { onEditInstructions, instructions, runnableType, path }: Props = $props()
async function fillFormWithAI() {
aiChatManager.openChat()
aiChatManager.askAi(`Analyze the ${runnableType} form on this page and fill the inputs for me`)
}
// A session cannot reach this page's form (the preview is a separate editor,
// and form filling drives the DOM through NAVIGATOR mode), so the hand-off
// asks it to run the item instead. Naming the DEPLOYED version matters: the
// test_run_* tools prefer drafts, which is not what this page runs.
const sessionSource = $derived(
path
? {
target: { kind: runnableType, path } as const,
workspaceId: $workspaceStore ?? undefined,
seedPrompt:
`Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` +
`tell me what you chose, then run it.` +
(instructions ? `\n\nHow to choose the inputs:\n${instructions}` : '')
}
: undefined
)
</script>
<div class="my-3 p-3 bg-surface-secondary rounded-md relative flex flex-col gap-3">
<div class="flex flex-row gap-2 justify-between items-center">
<h3 class="text-sm font-medium">Fill the inputs with AI</h3>
<AskAiButton label="Fill with AI" onClick={fillFormWithAI} />
<!-- Heading stays neutral because the two branches do different things: the
hand-off runs the item, the legacy path fills the form. Each button
names its own action. A plain Button rather than AskAiButton, whose own
session branch would fire here too and open an empty session. -->
<h3 class="text-sm font-medium">AI can help with these inputs</h3>
<OpenInSessionButton
source={sessionSource}
label="Run in AI session"
tooltip="Open an AI session that picks inputs and runs this"
btnProps={{ iconOnly: false, startIcon: { icon: WandSparkles } }}
>
{#snippet fallback()}
<Button
unifiedSize="md"
startIcon={{ icon: WandSparkles }}
btnClasses={AIBtnClasses('default')}
on:click={fillFormWithAI}
>
Fill with AI
</Button>
{/snippet}
</OpenInSessionButton>
</div>
<div class="flex flex-row gap-2 items-center">
<p class="text-sm text-primary">
@@ -3,6 +3,9 @@
import { WandSparkles } from 'lucide-svelte'
import { aiChatManager } from './chat/AIChatManager.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
import { prefersSessionHandoff } from './chat/global/gate'
import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
interface Props {
label?: string
initialInput?: string
@@ -11,7 +14,20 @@
const { label, initialInput, onClick: onClickProp }: Props = $props()
// The label stays short ("Ask AI") for the search bar's inline row; the hover
// text is where "new AI session" fits.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
export function onClick() {
// No item to preview here — this carries a question, not a target — so the
// hand-off opens a bare session on the question alone.
if (handsOffToSession) {
onClickProp?.()
// Sent on arrival, matching the legacy path below (askAi sends straight
// away): the text is the question the user already typed.
void startSessionWithPrompt(initialInput ?? '', { autoSend: true })
return
}
aiChatManager.openChat()
if (initialInput) {
aiChatManager.askAi(initialInput, {
@@ -30,6 +46,7 @@
}}
unifiedSize="md"
btnClasses={AIBtnClasses('default')}
title={handsOffToSession ? 'Ask this in a new AI session' : 'Ask this in the AI chat'}
on:click={onClick}
>
{label}
@@ -7,63 +7,123 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { autoPlacement } from '@floating-ui/core'
import { WandSparkles } from 'lucide-svelte'
import { aiChatManager } from './chat/AIChatManager.svelte'
import { aiChatManager, type AIChatManager } from './chat/AIChatManager.svelte'
import { copilotInfo } from '$lib/aiStore'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import { getOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext'
import { AIBtnClasses } from './chat/AIButtonStyle'
import { getContext } from 'svelte'
let {
lang
lang,
error,
jobId,
moduleId
}: {
lang: SupportedLanguage
/** The failing run's error, used when there is no job to point at. */
error?: string
/** The failing run's job id. Preferred over `error`: the chat reads the
* run itself with `get_job_logs`, which gives it the logs rather than
* just the thrown value, and keeps the composer readable. */
jobId?: string
/** Set when this sits in a flow step's preview, so the session opens on
* that step rather than the flow root. */
moduleId?: string
} = $props()
// The enclosing editor's "Open in AI session" hand-off (ScriptEditor for a
// standalone script, FlowBuilder for a step).
const handoff = getOpenInSessionHandoff()
const seedPrompt = $derived.by(() => {
const what = moduleId ? `step \`${moduleId}\`` : 'this script'
if (jobId) {
return `The last test run of ${what} failed (job \`${jobId}\`). Read its logs, then fix the code.`
}
// No job to read: the error text has to travel with the request.
return error
? `Fix this error in ${what}:\n\n\`\`\`\n${error}\n\`\`\``
: `Fix the error from the last run of ${what}.`
})
const sessionSource = $derived.by(() => {
const source = handoff?.source({ moduleId })
return source ? { ...source, seedPrompt, autoSend: true } : undefined
})
// Inside a session pane the chat is already beside this panel, so there is
// nothing to hand off to: send into that chat instead. OpenInSessionButton
// renders nothing there, which would otherwise leave the sessions population
// with no fix affordance at all.
const sessionScopedManager = getContext<AIChatManager | undefined>('aiChatManager')
</script>
{#if SUPPORTED_LANGUAGES.has(lang)}
<Popover
floatingConfig={{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-end', 'top-end']
})
]
}}
displayArrow={true}
>
{#snippet trigger()}
<div class="flex flex-row">
<Button
title="Fix code"
size="xs"
color="light"
spacingSize="xs2"
startIcon={{ icon: WandSparkles }}
on:click={() => {
if ($copilotInfo.enabled) {
aiChatManager.fix()
}
}}
btnClasses="text-ai bg-violet-100 dark:bg-gray-700 min-w-[84px]"
propagateEvent={!$copilotInfo.enabled}
>
AI Fix
</Button>
</div>
{#if sessionScopedManager}
<Button
title="Fix the failing run in this chat"
size="xs"
color="light"
spacingSize="xs2"
startIcon={{ icon: WandSparkles }}
on:click={() => sessionScopedManager.sendOrQueue(seedPrompt)}
btnClasses={AIBtnClasses('default')}
>
AI Fix
</Button>
{:else}
<OpenInSessionButton
source={sessionSource}
btnClasses={AIBtnClasses('default')}
label="Fix in AI session"
tooltip="Open an AI session on this item and fix the failing run"
btnProps={{ iconOnly: false, startIcon: { icon: WandSparkles } }}
>
{#snippet fallback()}
<Popover
floatingConfig={{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-end', 'top-end']
})
]
}}
displayArrow={true}
>
{#snippet trigger()}
<div class="flex flex-row">
<Button
title="Fix code"
size="xs"
color="light"
spacingSize="xs2"
startIcon={{ icon: WandSparkles }}
on:click={() => {
if ($copilotInfo.enabled) {
aiChatManager.fix()
}
}}
btnClasses="text-ai bg-violet-100 dark:bg-gray-700 min-w-[84px]"
propagateEvent={!$copilotInfo.enabled}
>
AI Fix
</Button>
</div>
{/snippet}
{#snippet content()}
<div class="p-4">
<div class="w-80">
<p class="text-sm"
>Enable Windmill AI in the <a
class="inline-flex flex-row items-center gap-1"
href="{base}/workspace_settings?tab=ai"
target="_blank">workspace settings</a
></p
></div
>
</div>
{/snippet}
</Popover>
{/snippet}
{#snippet content()}
<div class="p-4">
<div class="w-80">
<p class="text-sm"
>Enable Windmill AI in the <a
class="inline-flex flex-row items-center gap-1"
href="{base}/workspace_settings?tab=ai"
target="_blank">workspace settings</a
></p
></div
>
</div>
{/snippet}
</Popover>
</OpenInSessionButton>
{/if}
{/if}
@@ -12,7 +12,8 @@
togglePanel,
btnClasses,
btnProps,
label = 'Open in AI session'
label = 'Open in AI session',
tooltip
}: {
togglePanel: () => void
btnClasses?: string
@@ -21,13 +22,19 @@
btnProps?: ComponentProps<typeof Button>
/** Tooltip + accessible text of the icon-only button. */
label?: string
/** Hover text, when the label alone doesn't say where the button leads.
* A host that renamed the button ("AI Fix") uses this to keep "in a new
* AI session" discoverable. Defaults to `label`. */
tooltip?: string
} = $props()
const hoverText = $derived(tooltip ?? label)
</script>
{#if $copilotInfo.enabled}
<DarkPopover>
{#snippet text()}
{label}
{hoverText}
{/snippet}
{@render button({ onPress: () => togglePanel() })}
</DarkPopover>
@@ -166,9 +166,21 @@
files: initialFiles ?? []
}))
)
// Report edits, never the mount-time value. The first run carries whatever the
// composer was constructed with, which is not something the user did: a
// composer deliberately mounted empty (its text is already in flight, or
// belongs to a session this view is only keeping warm) would otherwise report
// '' and overwrite the very prompt it was withholding.
let draftReported = false
$effect(() => {
const text = draft.text
untrack(() => onDraftChange?.(text))
untrack(() => {
if (!draftReported) {
draftReported = true
return
}
onDraftChange?.(text)
})
})
// Images being decoded right now. Holds off sending so a message can never go
// out without an attachment the user already dropped, and reserves cap slots
@@ -1774,6 +1774,22 @@ export class AIChatManager {
}
}
/** Send `text` as a turn, or queue it when one is already streaming. Callers
* that send programmatically (an editor button, an arriving hand-off) must go
* through this rather than `sendRequest`: a second concurrent loop shares this
* manager's abort controller and transcript, so the two interleave and Stop
* halts only one. It is the rule the composer already follows.
*
* Gated on `sendInFlight` as well as `loading`: `loading` only rises after a
* send's attachment upkeep, so between the two a click would slip past. */
sendOrQueue(text: string) {
if (this.loading || this.sendInFlight) {
this.queueMessage(text)
return
}
void this.sendRequest({ instructions: text })
}
/** Remove the queued message and put it back into the input, images included. */
dequeueMessage() {
if (!this.#hasQueuedMessage()) {
@@ -2609,6 +2625,14 @@ export class AIChatManager {
}
sendRequest = async (options: Parameters<typeof this.sendRequestImpl>[0] = {}) => {
// A turn with nowhere to render still streams, spends tokens and applies
// tool calls — entirely off-screen. Refuse instead. `sendInlineRequest` is
// exempt: the ⌘K widget renders its own composer inside Monaco.
if (!this.isSessionChat && !chatState.dockedChatAvailable) {
console.error('sendRequest called with no chat UI mounted; dropping the turn')
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
return
}
this.#sendsInFlight++
try {
return await this.sendRequestImpl(options)
@@ -6,6 +6,7 @@ import type { ReviewChangesOpts } from './monaco-adapter'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import type { AttachedImage } from './imageUtils'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { chatState } from './sharedChatState.svelte'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { runChatLoop } from './chatLoop'
@@ -129,6 +130,9 @@ vi.mock('esm-env', async (importOriginal) => ({
}))
beforeEach(() => {
// These managers stand in for a mounted docked chat; without a layout to set
// it, sendRequest's "nowhere to render this turn" guard would refuse every send.
chatState.dockedChatAvailable = true
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(undefined)
mocks.tryGetCurrentModel.mockReturnValue(undefined)
@@ -172,6 +176,66 @@ function createFlowHelpers({
} as unknown as FlowAIChatHelpers
}
describe('AIChatManager unmounted-chat guard', () => {
// AI Sessions leave the docked pane unmounted, so an entry point that still
// drives this manager would otherwise stream and apply tool calls off-screen.
it('drops the turn when no chat UI is mounted, unless it is a session chat', async () => {
chatState.dockedChatAvailable = false
const docked = new AIChatManager()
docked.instructions = 'do a thing'
await docked.sendRequest()
expect(mocks.runChatLoop).not.toHaveBeenCalled()
const session = new AIChatManager()
session.isSessionChat = true
session.instructions = 'do a thing'
await session.sendRequest()
expect(mocks.runChatLoop).toHaveBeenCalled()
})
})
describe('AIChatManager.sendOrQueue', () => {
// The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no
// composer to enforce the composer's rule for them: a second loop on one manager
// shares its abort controller and transcript.
it('queues instead of starting a second turn while one is streaming', () => {
const manager = new AIChatManager()
manager.loading = true
manager.sendOrQueue('fix the failing run')
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.queuedMessage).toBe('fix the failing run')
})
it('sends straight away when idle', async () => {
const manager = new AIChatManager()
manager.sendOrQueue('fix the failing run')
await vi.waitFor(() => expect(mocks.runChatLoop).toHaveBeenCalled())
expect(manager.queuedMessage).toBe('')
})
// `loading` only rises after a send's attachment upkeep, so gating on it alone
// leaves a window where a second programmatic send slips through.
it('queues during a send that has not reached loading yet', async () => {
const manager = new AIChatManager()
let releaseUpkeep: (() => void) | undefined
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(
() => new Promise<void>((resolve) => (releaseUpkeep = resolve))
)
manager.instructions = 'first turn'
const sending = manager.sendRequest()
await vi.waitFor(() => expect(manager.sendInFlight).toBe(true))
expect(manager.loading).toBe(false)
manager.sendOrQueue('fix the failing run')
expect(manager.queuedMessage).toBe('fix the failing run')
// Drain before leaving: a send still in flight would run its epilogue
// (queue flush included) inside whichever test happens to be next.
releaseUpkeep?.()
await sending
})
})
describe('AIChatManager request errors', () => {
const openaiModel = { provider: 'openai', model: 'gpt-4o' }
@@ -25,6 +25,16 @@ export function isGlobalAiEnabled(): boolean {
}
}
/**
* Whether an AI entry point hands off to a session instead of driving the docked
* chat. Deliberately the same condition as the root layout's `disableAi`, so a
* caller falling back on `false` always has a mounted pane to fall back to.
* Operators keep that pane (`/sessions` refuses them) until the operator chat ships.
*/
export function prefersSessionHandoff(isOperator: boolean | undefined): boolean {
return isGlobalAiEnabled() && !isOperator
}
/** Persist the opt-out choice, then hard-reload so every gated site re-reads it. */
export function setSessionsBetaOptOut(optOut: boolean, target: string) {
// Navigate even when persistence throws (quota, private browsing) — the
@@ -35,6 +35,9 @@
import { Button } from '../common'
import { MousePointerClick, X } from 'lucide-svelte'
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
@@ -273,6 +276,12 @@
aiChatManager.flowOptions = options
})
// The step exists but is empty, so name it: a GLOBAL-mode request carries no
// implicit "current step" the way the old SCRIPT-mode generateStep did.
function stepInstructionsPrompt(moduleId: string, instructions: string): string {
return `Write the code for step \`${moduleId}\` of the flow open in the editor:\n\n${instructions}`
}
onMount(() => {
if (modalPanel) {
selectionManager.setOnSelectIntent((id, opts) => {
@@ -368,6 +377,32 @@
{showJobStatus}
on:reload
on:generateStep={({ detail }) => {
// The step is already inserted; the prompt describes what it should
// contain. Hand it to a session opened on that step rather than the
// docked chat, which sessions leave unmounted. Sent on arrival: the
// user already said what they wanted in the description field.
if (
!sessionScopedManager &&
sessionOpen &&
prefersSessionHandoff($userStore?.operator)
) {
void openSourceInSession(sessionOpen, {
previewParams: { selected: detail.moduleId },
seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions),
autoSend: true
})
return
}
// Already in a session: its chat is on screen, so ask it directly.
// Not `generateStep` — that forces the request into SCRIPT mode, and
// changeMode is persistent, so it would strand the session outside
// GLOBAL. Global mode writes step code through set_flow_module_code.
if (sessionScopedManager) {
sessionScopedManager.sendOrQueue(
stepInstructionsPrompt(detail.moduleId, detail.instructions)
)
return
}
if (!aiChatManager.open) {
aiChatManager.openChat()
}
@@ -61,6 +61,7 @@
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { setOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import {
buildDataTableWhitelist,
parseDataTableRef,
@@ -245,6 +246,17 @@
// so the preview just opens the app.
setOpenInSessionHandoff({ source: () => sessionOpen })
/** Hand this app off to a fresh AI session, seeding `seedPrompt` and sending
* it on arrival. Exposed for the template picker's "Start in AI session": the
* route owns the prompt, but the draft persistence the preview depends on
* lives here. False when there is no path to open yet, so the caller can fall
* back rather than swallow the click. */
export async function openInSession(seedPrompt: string): Promise<boolean> {
if (!sessionOpen) return false
await openSourceInSession(sessionOpen, { seedPrompt, autoSend: true })
return true
}
// Convert to object format for child components
let dataTableRefsObjects = $derived(data.tables.map(parseDataTableRef))
let dataTableWhitelist = $derived(buildDataTableWhitelist(dataTableRefsObjects))
@@ -14,6 +14,7 @@
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { react18Template, react19Template, svelte5Template } from './templates'
@@ -125,6 +126,9 @@
// would announce AI as unconfigured while it is merely unknown. Gate on the
// config describing opWs, and load it here so the claim owns its own evidence.
const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs)
// Say where the button leads: the route hands this prompt to a fresh AI
// session for everyone who has one, and drives the docked chat for the rest.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled)
$effect(() => {
@@ -398,8 +402,9 @@
}}
/>
<p class="text-xs text-tertiary">
Leave empty to start with a blank template, or describe your app to get AI assistance
right away.
{handsOffToSession
? 'Leave empty to start with a blank template, or describe your app to open an AI session that builds it.'
: 'Leave empty to start with a blank template, or describe your app to get AI assistance right away.'}
</p>
</div>
{/if}
@@ -424,7 +429,7 @@
startIcon={{ icon: Sparkles }}
btnClasses={AIBtnClasses('accent')}
>
Start with AI
{handsOffToSession ? 'Start in AI session' : 'Start with AI'}
</Button>
{/if}
</div>
@@ -214,8 +214,12 @@
fixTableSizingToParent
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)}
<ScriptFix {lang} />
{@const previewError =
previewJob && !previewJob.success
? getStringError(previewJob.result)
: undefined}
{#if lang && editor && diffEditor && args && previewError}
<ScriptFix {lang} error={previewError} jobId={previewJob?.id} />
{/if}
{/snippet}
</DisplayResult>
@@ -10,6 +10,14 @@
/** Where inside the item the preview should open (a flow's `selected`
* step). Steers the editor only — tab identity is (kind, path). */
previewParams?: Record<string, string>
/** Pre-fills the new session's composer. Entry points that carry an intent
* (fix this error, run this item) hand it over as text rather than driving
* a chat the caller cannot see. */
seedPrompt?: string
/** Send `seedPrompt` on arrival rather than parking it in the composer.
* For clicks that already stated the intent; leave it off where the prompt
* is a proposal the user should read first. */
autoSend?: boolean
}
// A destination is either an editable item or a page, never both and never
@@ -31,15 +39,17 @@
import { BROWSER } from 'esm-env'
import AIButton from '$lib/components/copilot/chat/AIButton.svelte'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { openEditorInSession, openPageInSession } from './sessionSwitch.svelte'
import { openSourceInSession } from './sessionSwitch.svelte'
let {
source,
btnClasses,
btnProps,
label,
tooltip,
fallback
}: {
/** Undefined (e.g. an item without a path yet) renders the fallback. */
@@ -48,9 +58,15 @@
/** Button styling overrides for hosts with their own conventions (an
* editor toolbar). */
btnProps?: ComponentProps<typeof AIButton>['btnProps']
/** Rendered instead when the user opted out of the sessions beta
* (typically the editor's inline-chat toggle). Never rendered inside
* the session panel. */
/** Names the action this replaced, for hosts whose button carried its own
* label ("AI Fix"). Defaults to AIButton's generic "Open in AI session". */
label?: string
/** Hover text. Pass it whenever `label` is set: a renamed button no longer
* says that clicking it leaves for a session. */
tooltip?: string
/** Rendered instead when the caller keeps a docked chat to drive — an
* opted-out user or an operator (typically the editor's inline-chat
* toggle). Never rendered inside the session panel. */
fallback?: Snippet
} = $props()
@@ -60,13 +76,11 @@
// SessionEditorTarget / the session wrapper); iframe preview tabs are not
// the top window.
const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top)
// The sessions page refuses operators, so an entry point on a page they can
// reach (Runs, the trigger lists) would only route them into that refusal.
// prefersSessionHandoff carries the operator clause: the sessions page refuses
// them, so an entry point on a page they can reach (Runs, the trigger lists)
// would only route them into that refusal.
const show = $derived(
!inSessionPanel &&
!!(source?.target || source?.page) &&
!$userStore?.operator &&
isGlobalAiEnabled()
!inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff($userStore?.operator)
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
@@ -75,16 +89,11 @@
if (opening || !source) return
opening = true
try {
// `beforeOpen` persists what is on screen and throws when it could not, so a
// failure has to stay on this page and say so — the session would otherwise
// open on an older draft than the editor the user is looking at.
await source.beforeOpen?.()
if (source.target) {
await openEditorInSession(source.target, source.workspaceId, source.previewParams)
} else {
const href = source.page?.()
if (href) await openPageInSession(href, source.workspaceId)
}
// `beforeOpen` (run inside openSourceInSession) persists what is on screen
// and throws when it could not, so a failure has to stay on this page and
// say so — the session would otherwise open on an older draft than the
// editor the user is looking at.
await openSourceInSession(source)
} catch (e) {
sendUserToast(e instanceof Error ? e.message : String(e), true)
} finally {
@@ -94,7 +103,13 @@
</script>
{#if show}
<AIButton togglePanel={open} btnClasses={btnClasses ?? AIBtnClasses('default')} {btnProps} />
<AIButton
togglePanel={open}
btnClasses={btnClasses ?? AIBtnClasses('default')}
{btnProps}
{label}
{tooltip}
/>
{:else if !inSessionPanel}
{@render fallback?.()}
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { setContext } from 'svelte'
import { setContext, untrack } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import SessionsBetaBanner from './SessionsBetaBanner.svelte'
@@ -38,6 +38,8 @@
moveSessionToWorkspace,
getSessionDraftPrompt,
setSessionDraftPrompt,
peekSessionAutoSend,
takeSessionAutoSend,
reconcileAfterWorkspaceChange,
renameSession,
selectSession,
@@ -76,6 +78,37 @@
// record (script-init: AIChatInput reads it once at mount).
const restoredDraftPrompt = getSessionDraftPrompt(sessionId)
// Whether the prompt read above is already on its way, so the composer starts
// empty instead of briefly showing text the effect below is about to send.
// Both halves of the claim's own condition are required, not just freshness:
// an intent this wrapper will not claim — stale, or armed on a session the
// page is only keeping warm in the background — still gets a composer, whose
// mount-time empty draft would otherwise erase the prompt from the record.
const armedAtInit =
sessionState.currentSessionId === sessionId && peekSessionAutoSend(sessionId)
// A hand-off whose click already stated the intent asks for its prompt to be
// sent, not parked. Reactive rather than mount-time: `createSession` reuses an
// untouched blank session and the sessions page keys wrappers by id, so a
// hand-off fired from `/sessions` can arm the session already on screen —
// whose wrapper never re-initializes, leaving an init-time claim to never run
// and the prompt to never be sent. Claimed only for the session the user
// landed on; a background wrapper would fire the turn off-screen.
$effect(() => {
if (sessionState.currentSessionId !== sessionId) return
if (!session?.autoSendDraftAt || !runtime) return
untrack(() => {
const prompt = getSessionDraftPrompt(sessionId)
// takeSessionAutoSend clears the intent whether or not it is still
// fresh, so this cannot re-enter on the next dependency change.
if (!takeSessionAutoSend(sessionId) || !prompt) return
// sendOrQueue, not sendRequest: a reused session may already be mid-turn.
// The runtime's beforeSend awaits loadCopilot for the committed
// workspace, so this cannot race the model config on a cold session.
runtime.manager.sendOrQueue(prompt)
})
})
// The workspace the session acts on, shown in the header "Acting on" strip via the shared
// WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets.
const acting = $derived.by(() => {
@@ -422,7 +455,7 @@
hideHeader
hideModeSelector
wideLayout
initialInstructions={restoredDraftPrompt}
initialInstructions={armedAtInit ? undefined : restoredDraftPrompt}
onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
@@ -130,6 +130,14 @@ export type Session = {
// the record so each parallel draft restores its own typed-but-unsent prompt.
// Only tracked while unsent; cleared once the workspace commits at first send.
draftPrompt?: string
// When `draftPrompt` should be sent on arrival rather than parked in the
// composer. Set by hand-offs whose click already stated the intent ("AI Fix",
// a typed step or app description), so they land mid-answer rather than
// waiting for a second Enter. Holds the arming time, not a flag: `goto`
// resolves even when a `beforeNavigate` cancels it, so an abandoned hand-off
// would otherwise leave a session armed forever and fire on some later visit.
// Consumed exactly once, by takeSessionAutoSend.
autoSendDraftAt?: number
}
// One preview tab: `url` is the URL we command the iframe to load, `loc` the
@@ -321,9 +329,9 @@ const draftPromptFlushHandles = new Map<string, ReturnType<typeof setTimeout>>()
export function setSessionDraftPrompt(sessionId: string, text: string): void {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s || s.workspace_id) return
// No-op on an unchanged prompt. Crucially, this treats the composer's
// mount-time onDraftChange('') as a non-touch (draftPrompt is undefined),
// so merely opening an untouched draft never persists it.
// No-op on an unchanged prompt, so opening an untouched draft never persists
// it. Writes of '' are real edits (a draft typed then erased is still a
// session), which is why the composer reports edits only — see AIChatInput.
if ((s.draftPrompt ?? '') === text) return
// Keep `transient` (means "in-memory only") set until the flush persists the
// draft, so hydrateSessions preserves it across a reconcile inside this window;
@@ -348,6 +356,44 @@ export function getSessionDraftPrompt(sessionId: string): string | undefined {
return s.draftPrompt
}
// A hand-off arrives within a navigation; anything older than this is the debris
// of one that never landed, and must not fire at whatever the user does next.
const AUTO_SEND_TTL_MS = 5 * 60_000
// Mark this session's draft prompt for sending on arrival. Written straight to
// the record (not via setSessionDraftPrompt's debounce) because the navigation
// that follows must not outrun it.
export function setSessionAutoSend(sessionId: string): void {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s) return
s.autoSendDraftAt = Date.now()
persistTouched(s)
}
function autoSendIsFresh(s: Session | undefined): boolean {
return !!s?.autoSendDraftAt && Date.now() - s.autoSendDraftAt < AUTO_SEND_TTL_MS
}
// Whether a claim would be honoured, without consuming it. The composer asks
// before deciding to stay empty: suppressing the text for an intent that then
// goes stale would leave the prompt neither sent nor shown, and the composer's
// own empty-draft write would erase it from the record.
export function peekSessionAutoSend(sessionId: string): boolean {
return autoSendIsFresh(sessionState.sessions.find((x) => x.id === sessionId))
}
// Claim the auto-send intent, clearing it so a remount (or a second wrapper for
// the same session) cannot fire the same prompt twice. A stale claim is dropped
// rather than honoured, but still cleared — it has no other consumer.
export function takeSessionAutoSend(sessionId: string): boolean {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s?.autoSendDraftAt) return false
const fresh = autoSendIsFresh(s)
delete s.autoSendDraftAt
persistTouched(s)
return fresh
}
// Persist a session on a genuine user edit, promoting an in-memory-only
// (transient) pending session to a durable IndexedDB record on first touch.
// Non-touch writers (runtime chatId seeding, unread watermark) call putSession
@@ -6,10 +6,15 @@ import {
selectSession,
sessionInCurrentFamily,
sessionState,
setSessionAutoSend,
setSessionDraftPrompt,
setSessionPendingWorkspace,
type SessionTarget
} from './sessionState.svelte'
import { sessionTargetHref, withPreviewParams } from './sessionMode.svelte'
// Type-only: erased at compile time, so the component graph stays out of this
// navigation seam (see the dynamic import in openEditorInSession).
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
// The session/navigation switch turns the global rail into either the workspace
// navigation (navigation mode) or the sessions sidebar (session mode). Session
@@ -75,24 +80,37 @@ export async function exitSessionMode(): Promise<void> {
export async function openEditorInSession(
target: SessionTarget,
workspaceId?: string,
previewParams?: Record<string, string>
previewParams?: Record<string, string>,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
await openInSession(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId)
await openInSession(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId, opts)
}
// Open a fresh AI session showing a workspace page (Runs, a trigger list) in its
// preview. A page is not an editable item, so callers hand over the in-app href
// they want the tab to load rather than a SessionTarget.
export async function openPageInSession(href: string, workspaceId?: string): Promise<void> {
await openInSession(href, workspaceId)
export async function openPageInSession(
href: string,
workspaceId?: string,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
await openInSession(href, workspaceId, opts)
}
async function openInSession(url: string | undefined, workspaceId?: string): Promise<void> {
async function openInSession(
url: string | undefined,
workspaceId?: string,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
// Seed the fresh session's preview with a single tab on `url` so it opens
// straight onto what the caller wants (resetSessionPreviewTabs also writes
// through a live runtime if one already exists for this id).
const session = createSession()
if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId)
if (opts?.seedPrompt) {
setSessionDraftPrompt(session.id, opts.seedPrompt)
if (opts.autoSend) setSessionAutoSend(session.id)
}
if (url) {
// Dynamic import: a static one would drag the runtime's heavy graph
// (chat manager → monaco) into this thin navigation seam, breaking its
@@ -103,3 +121,49 @@ async function openInSession(url: string | undefined, workspaceId?: string): Pro
selectSession(session.id)
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
}
// Open an editor's own hand-off, running its `beforeOpen` (which persists the
// draft the preview loads) first. Callers that drive the hand-off imperatively
// go through this rather than `openEditorInSession` so they cannot skip that
// step; `OpenInSessionButton` is the declarative equivalent.
export async function openSourceInSession(
source: OpenInSessionSource,
overrides?: { previewParams?: Record<string, string>; seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
await source.beforeOpen?.()
const opts = {
seedPrompt: overrides?.seedPrompt ?? source.seedPrompt,
autoSend: overrides?.autoSend ?? source.autoSend
}
if (source.target) {
await openEditorInSession(
source.target,
source.workspaceId,
overrides?.previewParams ?? source.previewParams,
opts
)
return
}
const href = source.page?.()
if (href) await openPageInSession(href, source.workspaceId, opts)
}
// Open a fresh session on no particular item, with `prompt` pre-filled in the
// composer. For entry points that carry a question rather than a target (the
// global search's "Ask AI"). Always a new session rather than the most recent
// one (`enterSessionMode`), so the seed cannot overwrite a prompt the user has
// already typed into a session they are mid-way through.
export async function startSessionWithPrompt(
prompt: string,
opts?: { autoSend?: boolean }
): Promise<void> {
// No setSessionPendingWorkspace: createSession already picked the workspace,
// steering off a root the user cannot deploy to onto its dev. Overwriting it
// with the raw current workspace would land the session where it cannot edit.
const session = createSession()
setSessionDraftPrompt(session.id, prompt)
// An empty prompt has nothing to send; leave the composer focused instead.
if (opts?.autoSend && prompt.trim()) setSessionAutoSend(session.id)
selectSession(session.id)
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
}
@@ -1,12 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get } from 'svelte/store'
import { enterSessionMode } from './sessionSwitch.svelte'
import { sessionState, type Session } from './sessionState.svelte'
import {
enterSessionMode,
openSourceInSession,
startSessionWithPrompt
} from './sessionSwitch.svelte'
import {
peekSessionAutoSend,
sessionState,
takeSessionAutoSend,
type Session
} from './sessionState.svelte'
import { usersWorkspaceStore, workspaceStore, type UserWorkspace } from '$lib/stores'
vi.mock('$lib/navigation', () => ({ goto: vi.fn().mockResolvedValue(undefined) }))
import { goto } from '$lib/navigation'
// Seeding a preview tab dynamically imports the runtime, whose graph reaches
// monaco (hence that import being dynamic in the first place) and cannot load
// under node.
vi.mock('./sessionRuntime.svelte', () => ({ resetSessionPreviewTabs: vi.fn() }))
function session(over: Partial<Session> = {}): Session {
return { id: 's1', name: 'sess', createdAt: 0, ...over }
}
@@ -43,7 +57,9 @@ describe('enterSessionMode — restore is scoped to the active family', () => {
try {
await enterSessionMode()
expect(sessionState.currentSessionId).toBe('sw-in-family')
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-911', { replaceState: false })
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-911', {
replaceState: false
})
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'sw-in-family')
sessionState.currentSessionId = prevCurrent
@@ -61,7 +77,9 @@ describe('enterSessionMode — restore is scoped to the active family', () => {
try {
await enterSessionMode()
expect(sessionState.currentSessionId).toBe('sw-local')
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-913', { replaceState: false })
expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-913', {
replaceState: false
})
} finally {
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'sw-foreign' && s.id !== 'sw-local'
@@ -94,3 +112,139 @@ describe('enterSessionMode — restore is scoped to the active family', () => {
}
})
})
describe('openSourceInSession', () => {
// The wrapper exists so an imperative caller cannot route before the source has
// persisted the draft the preview loads.
it('runs beforeOpen before routing, and lets overrides win over the source', async () => {
vi.mocked(goto).mockClear()
const order: string[] = []
vi.mocked(goto).mockImplementation((async () => {
order.push('goto')
}) as never)
const prevCurrent = sessionState.currentSessionId
let createdId: string | undefined
try {
await openSourceInSession(
{
target: { kind: 'script', path: 'u/me/s' },
beforeOpen: () => {
order.push('beforeOpen')
},
seedPrompt: 'from source'
},
{ seedPrompt: 'from override' }
)
createdId = sessionState.currentSessionId
expect(order).toEqual(['beforeOpen', 'goto'])
const created = sessionState.sessions.find((s) => s.id === createdId)
expect(created?.draftPrompt).toBe('from override')
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId)
sessionState.currentSessionId = prevCurrent
vi.mocked(goto).mockReset()
vi.mocked(goto).mockResolvedValue(undefined as never)
}
})
})
// The auto-send intent must be claimable exactly once: SessionWrapper mounts per
// session and can remount, and a second claim would re-fire the same prompt as a
// duplicate turn.
describe('auto-send intent', () => {
it('is carried by the hand-off and consumed by the first claim only', async () => {
const prevCurrent = sessionState.currentSessionId
let createdId: string | undefined
try {
await openSourceInSession({
target: { kind: 'script', path: 'u/me/s' },
seedPrompt: 'fix it',
autoSend: true
})
createdId = sessionState.currentSessionId!
expect(peekSessionAutoSend(createdId)).toBe(true)
expect(takeSessionAutoSend(createdId)).toBe(true)
expect(takeSessionAutoSend(createdId)).toBe(false)
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId)
sessionState.currentSessionId = prevCurrent
}
})
// `goto` resolves even when a beforeNavigate cancels it, so an abandoned
// hand-off leaves the session armed. The claim must go stale rather than fire
// a turn at whatever the user is doing whenever that session next opens.
it('refuses a claim left over from a hand-off that never landed', async () => {
const prevCurrent = sessionState.currentSessionId
let createdId: string | undefined
try {
await openSourceInSession({
target: { kind: 'script', path: 'u/me/s' },
seedPrompt: 'fix it',
autoSend: true
})
createdId = sessionState.currentSessionId!
const s = sessionState.sessions.find((x) => x.id === createdId)!
s.autoSendDraftAt = Date.now() - 10 * 60_000
// The composer asks first, and must be told to show the prompt — blanking
// it for an intent that is never honoured loses the text altogether.
expect(peekSessionAutoSend(createdId)).toBe(false)
expect(takeSessionAutoSend(createdId)).toBe(false)
// Still cleared: a stale intent has no other consumer to leave it for.
expect(s.autoSendDraftAt).toBeUndefined()
expect(s.draftPrompt).toBe('fix it')
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId)
sessionState.currentSessionId = prevCurrent
}
})
it('is left unset when the caller only seeds the composer', async () => {
const prevCurrent = sessionState.currentSessionId
let createdId: string | undefined
try {
await openSourceInSession({
target: { kind: 'script', path: 'u/me/s' },
seedPrompt: 'pick inputs and run'
})
createdId = sessionState.currentSessionId!
expect(takeSessionAutoSend(createdId)).toBe(false)
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId)
sessionState.currentSessionId = prevCurrent
}
})
})
describe('startSessionWithPrompt', () => {
beforeEach(() => {
vi.mocked(goto).mockClear()
})
// The hand-off must not re-pick the workspace: createSession already steers off
// a root the user cannot deploy to onto its dev, and overwriting that with the
// raw current workspace lands the session somewhere it cannot edit.
it('seeds the composer and keeps createSessions workspace choice', async () => {
const prevUsers = get(usersWorkspaceStore)
const prevWs = get(workspaceStore)
const prevCurrent = sessionState.currentSessionId
usersWorkspaceStore.set({
email: 't@t',
workspaces: [ws('prod'), { ...ws('prod-dev', 'prod'), is_dev_workspace: true }]
} as never)
workspaceStore.set('prod')
let createdId: string | undefined
try {
await startSessionWithPrompt('list my flows')
createdId = sessionState.currentSessionId
const created = sessionState.sessions.find((s) => s.id === createdId)
expect(created?.draftPrompt).toBe('list my flows')
expect(created?.pending_workspace_id).toBe('prod-dev')
} finally {
sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId)
sessionState.currentSessionId = prevCurrent
usersWorkspaceStore.set(prevUsers)
workspaceStore.set(prevWs)
}
})
})
@@ -10,6 +10,7 @@
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { HiddenRunnable } from '$lib/components/apps/types'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import {
@@ -482,6 +483,8 @@
redraw++
}
let rawAppEditor: RawAppEditor | undefined = $state()
function onTemplatePickerStart(result: RawAppTemplatePickerResult, withPrompt: boolean) {
files = { ...result.files }
runnables = { ...result.runnables, [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
@@ -497,10 +500,19 @@
schema: result.data.schema
}
if (withPrompt && result.prompt) {
setTimeout(() => {
const prompt = result.prompt
// The delay lets the remount above settle: the session hand-off persists
// the draft the preview loads, and the docked path needs the editor to
// have registered its app helpers.
setTimeout(async () => {
// Falls through when the hand-off has no path to open, so the click
// still reaches the legacy path (or its toast) instead of vanishing.
if (prefersSessionHandoff($userStore?.operator)) {
if (await rawAppEditor?.openInSession(prompt)) return
}
aiChatManager.changeMode(AIMode.APP)
if (!aiChatManager.open) aiChatManager.toggleOpen()
aiChatManager.instructions = result.prompt!
aiChatManager.instructions = prompt
aiChatManager.sendRequest()
}, 500)
}
@@ -555,6 +567,7 @@
{#key redraw}
<div class="h-screen">
<RawAppEditor
bind:this={rawAppEditor}
onSavedNewAppPath={(savedPath) => {
draftSync.remove()
goto(`/apps_raw/edit/${savedPath}`)
@@ -321,7 +321,8 @@
label: editInForkLabel($workspaceStore, $userWorkspaces),
buttonProps: {
href: buildForkEditUrl('flow', flow.path),
onClick: (e: Event | undefined) => onEditInForkClick(e, 'flow', flow.path, { hasHref: true }),
onClick: (e: Event | undefined) =>
onEditInForkClick(e, 'flow', flow.path, { hasHref: true }),
unifiedSize: 'md',
variant: !showEditButtons ? 'default' : 'subtle',
startIcon: Pen
@@ -746,6 +747,7 @@
goto(`/flows/edit/${flow?.path}`)
}}
runnableType="flow"
path={flow?.path}
/>
{/if}
@@ -464,6 +464,10 @@
lines.push('No output asset is expected.')
}
const instructions = lines.join('\n')
// Still the docked chat, which AI Sessions leaves unmounted — sendRequest
// refuses rather than running the turn off-screen. Handing this off to a
// session needs the just-staged draft flushed to its DB bundle first (the
// preview hydrates from there), and PipelineGraphEditor exposes no flush.
aiChatManager.openChat()
aiChatManager.sendRequest({ instructions })
}
@@ -470,7 +470,8 @@
label: editInForkLabel($workspaceStore, $userWorkspaces),
buttonProps: {
href: buildForkEditUrl('script', script.path),
onClick: (e: Event | undefined) => onEditInForkClick(e, 'script', script.path, { hasHref: true }),
onClick: (e: Event | undefined) =>
onEditInForkClick(e, 'script', script.path, { hasHref: true }),
unifiedSize: 'md',
variant: !showEditButtons ? 'default' : 'subtle',
startIcon: Pen
@@ -969,6 +970,7 @@
goto(`/scripts/edit/${script?.path}?metadata_open=true`)
}}
runnableType="script"
path={script?.path}
/>
{/if}