diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 59f5583c2b1..a84e7f42caf 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -51,6 +51,7 @@ export type ComposerSession = { location: { command: Pick } session: { prompt: (input: Parameters[0]) => Promise + setStatus: Data["session"]["setStatus"] } } current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> @@ -77,7 +78,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & { start: ( selection: ComposerSelection, submission: ReturnType, - ) => Promise + ) => Promise<{ session: ComposerSession; cleanupReady: Promise } | undefined> } export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 3b0a5ca1e4b..0e9e7a13bf3 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -69,6 +69,7 @@ function submitInput( function session(input: { calls: string[] prompt: (value: Parameters[0]) => Promise + statuses?: ("idle" | "running")[] current?: ComposerSession["current"] admitted?: (messageID: string) => boolean shell?: () => Promise @@ -92,6 +93,7 @@ function session(input: { data: { location: { command: { list: () => [] } }, session: { + setStatus: (_sessionID, status) => input.statuses?.push(status), prompt: async (value) => { input.calls.push("prompt") await input.prompt(value) @@ -140,10 +142,12 @@ describe("Composer submission", () => { test("starts and promotes a New Session once before admitting its first prompt", async () => { const draft = createMemoryComposerState({ prompt: "first prompt" }).capture() - const promoted = createMemoryComposerState().capture() + const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture() const calls: string[] = [] + const statuses: ("idle" | "running")[] = [] const admitted = Promise.withResolvers[0]>() - const target = session({ calls, prompt: async (value) => admitted.resolve(value) }) + const cleanupReady = Promise.withResolvers() + const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, @@ -156,14 +160,20 @@ describe("Composer submission", () => { async start(_selection, submission) { calls.push("start") submission.retarget(promoted) - return target + return { session: target, cleanupReady: cleanupReady.promise } }, } - await submitInput(adapter).submit(new Event("submit")) + const submitted = submitInput(adapter).submit(new Event("submit")) const request = await admitted.promise - expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"]) + expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"]) + expect(statuses).toEqual(["running"]) + expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }]) + cleanupReady.resolve() + await submitted + + expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"]) expect(request.delivery).toBe("steer") expect(request.text).toBe("first prompt") expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) @@ -233,7 +243,7 @@ describe("Composer submission", () => { submitted() {}, async start(_selection, submission) { submission.retarget(promoted) - return target + return { session: target, cleanupReady: Promise.resolve() } }, } @@ -250,10 +260,12 @@ describe("Composer submission", () => { test("reuses the message ID when an unacknowledged admission is retried", async () => { const state = createMemoryComposerState({ prompt: "retry me" }).capture() const attempts: string[] = [] + const statuses: ("idle" | "running")[] = [] const first = Promise.withResolvers() const second = Promise.withResolvers() const target = session({ calls: [], + statuses, prompt: async (value) => { attempts.push(value.id ?? "") throw new Error("network unavailable") @@ -283,6 +295,7 @@ describe("Composer submission", () => { expect(attempts).toHaveLength(4) expect(new Set(attempts).size).toBe(1) + expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }]) }) diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 10879171880..4e6edc4c0ff 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -65,15 +65,42 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const comments = input.comments.capture() try { - const session = + const started = input.adapter.kind === "active-session" - ? input.adapter.session() + ? { session: input.adapter.session(), cleanupReady: Promise.resolve() } : await input.adapter.start(value.selection, submission) - if (!session) return + if (!started) return + const session = started.session input.addToHistory(value.prompt, value.mode) input.resetHistory() const restore = () => restoreSubmission(input, submission, value, comments) + + const command = value.mode === "normal" ? findCommand(session, value.text) : undefined + if (value.mode === "normal" && !command) { + const optimisticBusy = !input.adapter.working() + if (optimisticBusy) session.data.session.setStatus(session.id, "running") + const sending = sendPrompt(session, value).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ) + await started.cleanupReady + input.adapter.submitted() + submission.context + .filter((item) => !!item.comment?.trim()) + .forEach((item) => submission.target().context.remove(item.key)) + input.comments.clear() + clearSubmission(input, submission) + void sending.then((result) => { + if (!result.ok) + failSubmission(input, session, "prompt", result.error, restore, value.id, () => { + if (optimisticBusy) session.data.session.setStatus(session.id, "idle") + }) + }) + return + } + + await started.cleanupReady input.adapter.submitted() if (value.mode === "shell") { @@ -82,7 +109,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { return } - const command = findCommand(session, value.text) if (command) { clearSubmission(input, submission) void sendCommand(session, value, command).catch((error) => @@ -91,14 +117,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { return } - submission.context - .filter((item) => !!item.comment?.trim()) - .forEach((item) => submission.target().context.remove(item.key)) - input.comments.clear() - clearSubmission(input, submission) - void sendPrompt(session, value).catch((error) => - failSubmission(input, session, "prompt", error, restore, value.id), - ) } finally { submitting.delete(input.adapter.state) } @@ -313,8 +331,10 @@ function failSubmission( error: unknown, restore: () => boolean, messageID?: string, + rollback?: () => void, ) { if (messageID && session.admitted(messageID)) return + rollback?.() restore() input.notify.failed(kind, error) } diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 548669aa9fc..7aa678e15de 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -53,26 +53,32 @@ export function createNewSessionComposerAdapter(props: { }) if (!sessionDirectory) return - const created = await serverSDK.api.session - .create({ - agent: selection.agent, - model: { - id: selection.model.modelID, - providerID: selection.model.providerID, - variant: selection.variant, - }, - location: { directory: sessionDirectory }, - }) - .catch((error) => { + const created = data.session.create({ + agent: selection.agent, + model: { + id: selection.model.modelID, + providerID: selection.model.providerID, + variant: selection.variant, + }, + location: { directory: sessionDirectory }, + }) + const creation = created.request.then( + () => ({ ok: true as const }), + (error) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(language, error), }) - }) - if (!created) return + return { ok: false as const, error } + }, + ) + const afterCreation = async (run: () => Promise) => { + const result = await creation + if (!result.ok) throw result.error + return run() + } - data.session.remember(created) - await startTransition(() => { + const cleanupReady = startTransition(() => { tabs.updateDraft(props.draftID, { worktree: undefined }) if (permission.isAutoAcceptingDirectory(projectDirectory)) { permission.enableAutoAccept(created.id, sessionDirectory) @@ -92,13 +98,31 @@ export function createNewSessionComposerAdapter(props: { }) return { - id: created.id, - directory: sessionDirectory, - api: serverSDK.api.session, - data, - current: () => data.session.get(created.id) ?? created, - admitted: (messageID) => - data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID), + cleanupReady, + session: { + id: created.id, + directory: sessionDirectory, + api: { + command: (input) => afterCreation(() => serverSDK.api.session.command(input)), + shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)), + switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)), + switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)), + }, + data: { + location: data.location, + session: { + setStatus: data.session.setStatus, + prompt: (input) => + data.session.prompt({ + ...input, + gate: Promise.all([input.gate, afterCreation(async () => undefined)]), + }), + }, + }, + current: () => data.session.get(created.id), + admitted: (messageID) => + data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID), + }, } }, } diff --git a/packages/app/src/session/composer/adapter.ts b/packages/app/src/session/composer/adapter.ts index cc4f4023120..a7ef4402638 100644 --- a/packages/app/src/session/composer/adapter.ts +++ b/packages/app/src/session/composer/adapter.ts @@ -16,6 +16,7 @@ export function createActiveComposerAdapter(input: { if (!id) throw new Error("Active Composer requires a Session ID") const prompt = useComposerState() + prompt.current() const state = prompt.capture() const data = useData() const server = useServerSDK() diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 9000f01cdfa..3f85866074a 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -204,9 +204,7 @@ export function ActiveSessionComposerRegion(props: { }) { const region = createSessionComposerRegionController({ state: props.model.region.state, - sessionKey: props.session.identity.sessionKey, sessionID: () => props.session.identity.params.id, - prompt: props.model.region.prompt, centered: props.model.region.centered, onResponseSubmit: props.onResponseSubmit, openParent: props.model.region.openParent, diff --git a/packages/app/src/session/composer/session-composer-region-controller.ts b/packages/app/src/session/composer/session-composer-region-controller.ts index 100f8cf6d1e..55e8c1047f4 100644 --- a/packages/app/src/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/session/composer/session-composer-region-controller.ts @@ -1,14 +1,10 @@ -import { type Accessor, createEffect, createMemo, createResource } from "solid-js" -import type { useComposerState } from "@/composer/persistence" +import { type Accessor, createMemo } from "solid-js" import { useData } from "@/runtime/server/current" -import { getSessionHandoff, setSessionHandoff } from "@/session/handoff" import type { SessionRequestModel } from "../requests/model" export function createSessionComposerRegionController(input: { state: SessionRequestModel - sessionKey: Accessor sessionID: Accessor - prompt: ReturnType centered: Accessor onResponseSubmit: () => void openParent: () => void @@ -16,32 +12,10 @@ export function createSessionComposerRegionController(input: { setDockRef: (el: HTMLDivElement) => void }) { const data = useData() - createEffect(() => { - if (!input.prompt.ready()) return - setSessionHandoff(input.sessionKey(), { - prompt: input.prompt - .current() - .map((part) => { - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - if (part.type === "image") return `[image:${part.filename}]` - return part.content - }) - .join("") - .trim(), - }) - }) - const parentID = createMemo(() => { const id = input.sessionID() return id ? data.session.get(id)?.parentID : undefined }) - const ready = Promise.resolve() - const [promptReady] = createResource( - () => input.prompt.ready.promise ?? ready, - (promise) => promise.then(() => true), - ) - return { state: input.state, centered: input.centered, @@ -52,8 +26,6 @@ export function createSessionComposerRegionController(input: { parentID, child: () => !!parentID(), showComposer: () => !input.state.blocked() || !!parentID(), - handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, - promptReady: () => input.prompt.ready() || promptReady(), } } diff --git a/packages/app/src/session/composer/session-composer-region.tsx b/packages/app/src/session/composer/session-composer-region.tsx index 1c25e9f43f6..0fff887efc3 100644 --- a/packages/app/src/session/composer/session-composer-region.tsx +++ b/packages/app/src/session/composer/session-composer-region.tsx @@ -19,8 +19,6 @@ export type SessionComposerRegionViewController = Pick< | "parentID" | "child" | "showComposer" - | "handoffPrompt" - | "promptReady" > & { state: SessionComposerRegionState } export function SessionComposerRegion(props: { @@ -65,43 +63,32 @@ export function SessionComposerRegion(props: { - -
- {controller.handoffPrompt() || language.t("prompt.loading")} -
- - } +
-
{props.composer}} > - {props.composer}} +
-
- {language.t("session.child.promptDisabled")} - - - -
- -
- + {language.t("session.child.promptDisabled")} + + + +
+ +
diff --git a/packages/app/src/session/handoff.ts b/packages/app/src/session/handoff.ts index f8fbd4a7665..128bcade43c 100644 --- a/packages/app/src/session/handoff.ts +++ b/packages/app/src/session/handoff.ts @@ -1,7 +1,6 @@ import type { SelectedLineRange } from "@/workspaces/files/model" type HandoffSession = { - prompt: string files: Record } @@ -23,7 +22,7 @@ const touch = (map: Map, key: K, value: V) => { } export const setSessionHandoff = (key: string, patch: Partial) => { - const prev = store.session.get(key) ?? { prompt: "", files: {} } + const prev = store.session.get(key) ?? { files: {} } touch(store.session, key, { ...prev, ...patch }) } diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 47679380c22..74ca3c9d119 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -208,8 +208,6 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void parentID: () => props.child?.parentID, child: () => !!props.child, showComposer: () => true, - handoffPrompt: () => undefined, - promptReady: () => true, } satisfies SessionComposerRegionViewController return (