diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index f518632483..987aa937eb 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -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 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') // 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() } }) diff --git a/frontend/src/lib/components/ModulePreviewResultViewer.svelte b/frontend/src/lib/components/ModulePreviewResultViewer.svelte index 6a0fef6f79..3f39531b0a 100644 --- a/frontend/src/lib/components/ModulePreviewResultViewer.svelte +++ b/frontend/src/lib/components/ModulePreviewResultViewer.svelte @@ -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)} - + {@const stepError = + selectedJob?.type === 'CompletedJob' && !selectedJob.success + ? getStringError(selectedJob.result) + : undefined} + {#if lang && editor && diffEditor && stepsInputArgs.getStepArgs(mod.id) && stepError} + {/if} {/snippet} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 13f3de2a7a..7bc4774e9f 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -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) }) diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index 4678342a57..ba8ed67084 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -1,27 +1,67 @@
-

Fill the inputs with AI

- + +

AI can help with these inputs

+ + {#snippet fallback()} + + {/snippet} +

diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 52c810e5e3..95ff47fb52 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -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} diff --git a/frontend/src/lib/components/copilot/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index 2deaef0193..bbd9341700 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -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') {#if SUPPORTED_LANGUAGES.has(lang)} - - {#snippet trigger()} - -

- -
- + {#if sessionScopedManager} + + {:else} + + {#snippet fallback()} + + {#snippet trigger()} +
+ +
+ {/snippet} + {#snippet content()} +
+
+

Enable Windmill AI in the workspace settings

+
+ {/snippet} +
{/snippet} - {#snippet content()} - -
-
-

Enable Windmill AI in the workspace settings

-
- - {/snippet} - +
+ {/if} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIButton.svelte b/frontend/src/lib/components/copilot/chat/AIButton.svelte index 3fe9acec77..ade1bd2d86 100644 --- a/frontend/src/lib/components/copilot/chat/AIButton.svelte +++ b/frontend/src/lib/components/copilot/chat/AIButton.svelte @@ -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 /** 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) {#if $copilotInfo.enabled} {#snippet text()} - {label} + {hoverText} {/snippet} {@render button({ onPress: () => togglePanel() })} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index a377a7e370..70ec3f3fc1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a1c27b0e32..ee1ef5ab96 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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[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) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index e4574ee9c1..5b5c1138cb 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -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((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' } diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index f3eb731ae2..3321d5a0cd 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -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 diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index a8c18f70ba..eeb4101f92 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -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') const sessionScopedManager = getContext('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() } diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 10a6096391..0778fda4e4 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -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 { + 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)) diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index efcae2af6a..fc8abd5d5c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -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 @@ }} />

- 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.'}

{/if} @@ -424,7 +429,7 @@ startIcon={{ icon: Sparkles }} btnClasses={AIBtnClasses('accent')} > - Start with AI + {handsOffToSession ? 'Start in AI session' : 'Start with AI'} {/if}
diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index f253beadc7..010369a663 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -214,8 +214,12 @@ fixTableSizingToParent > {#snippet copilot_fix()} - {#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)} - + {@const previewError = + previewJob && !previewJob.success + ? getStringError(previewJob.result) + : undefined} + {#if lang && editor && diffEditor && args && previewError} + {/if} {/snippet} diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 22f1167455..00bf1c829e 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -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 + /** 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['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 @@ {#if show} - + {:else if !inSessionPanel} {@render fallback?.()} {/if} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 9472819eea..52d9e476fb 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -1,5 +1,5 @@