mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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 createSession’s 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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user