diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 481b7922cf36..deed2ebd45ac 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -91,6 +91,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): const platform = usePlatform() const prompt = props.state ?? usePrompt() let editor: HTMLDivElement | undefined + // Tracks a per-message steer override: Ctrl+Enter forces a steer while the + // session is busy. Consumed once by getDelivery during submit. + let steerOverride = false const interaction = createPromptInputV2State() const mode = () => interaction[0].mode @@ -215,6 +218,13 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, + getDelivery: () => { + if (steerOverride) { + steerOverride = false + return "steer" + } + return props.getDelivery?.() ?? "queue" + }, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, @@ -406,6 +416,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): onSubmit: () => void submission.handleSubmit(new Event("submit")), onStop: () => void submission.abort(), }, + onKeyDown: (event) => { + steerOverride = event.key === "Enter" && event.ctrlKey + }, }, }) Object.defineProperty(controller, "model", { get: () => props.controls.model }) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 41cf7d30ca34..ccbaddb57a4d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -284,6 +284,7 @@ export const PromptInput: Component = (props) => { return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 }) const stopping = createMemo(() => working() && blank()) + const queueHint = () => working() && !stopping() && props.getDelivery?.() === "queue" const tip = () => { if (stopping()) { return ( @@ -293,6 +294,13 @@ export const PromptInput: Component = (props) => { ) } + if (queueHint()) { + return ( +
+ {language.t("prompt.hint.queueSteer")} +
+ ) + } return (
@@ -1223,6 +1231,7 @@ export const PromptInput: Component = (props) => { newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, + getDelivery: props.getDelivery, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, @@ -1584,7 +1593,13 @@ export const PromptInput: Component = (props) => { icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"} variant="primary" class="size-8" - aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} + aria-label={ + stopping() + ? language.t("prompt.action.stop") + : queueHint() + ? language.t("prompt.action.queue") + : language.t("prompt.action.send") + } />
diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts index a38f45d9256e..6a2fcc7f0936 100644 --- a/packages/app/src/components/prompt-input/contracts.ts +++ b/packages/app/src/components/prompt-input/contracts.ts @@ -1,7 +1,7 @@ import type { useLocal } from "@/context/local" import type { Prompt, usePrompt } from "@/context/prompt" import type { PromptInputHistory } from "./history-store" -import type { FollowupDraft } from "./submit" +import type { FollowupDelivery, FollowupDraft } from "./submit" export type PromptInputState = ReturnType @@ -51,6 +51,7 @@ export interface PromptInputProps { edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } onEditLoaded?: () => void shouldQueue?: () => boolean + getDelivery?: () => FollowupDelivery onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b3201b3ef68a..6888165622c7 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -135,6 +135,7 @@ beforeAll(async () => { mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, showToast: () => 0, + toaster: { dismiss: () => undefined }, })) mock.module("@opencode-ai/core/util/encode", () => ({ @@ -596,3 +597,86 @@ describe("prompt submit worktree selection", () => { expect(optimisticSeeded).toEqual([true]) }) }) + +describe("prompt submit follow-up delivery", () => { + const followupInput = (overrides: { + shouldQueue?: () => boolean + getDelivery?: () => "queue" | "steer" + onQueue?: (draft: { delivery?: string }) => void + }) => + createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + shouldQueue: overrides.shouldQueue, + getDelivery: overrides.getDelivery, + onQueue: overrides.onQueue, + }) + + test("queues follow-ups locally when delivery is queue", async () => { + params = { id: "session-1" } + const queued: Array<{ delivery?: string }> = [] + const submit = followupInput({ + shouldQueue: () => true, + getDelivery: () => "queue", + onQueue: (draft) => { + queued.push(draft) + }, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(queued).toHaveLength(1) + expect(queued[0]).toMatchObject({ sessionID: "session-1", delivery: "queue" }) + expect(sentPrompts).toHaveLength(0) + }) + + test("sends follow-ups directly with steer delivery", async () => { + params = { id: "session-1" } + const queued: unknown[] = [] + const submit = followupInput({ + shouldQueue: () => true, + getDelivery: () => "steer", + onQueue: (draft) => { + queued.push(draft) + }, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await Bun.sleep(0) + + expect(queued).toHaveLength(0) + expect(sentPrompts).toEqual(["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/repo/main"]) + expect(promptInputs[0]).toMatchObject({ sessionID: "session-1", delivery: "steer" }) + }) + + test("forces steer delivery on Ctrl+Enter", async () => { + params = { id: "session-1" } + const queued: unknown[] = [] + const submit = followupInput({ + shouldQueue: () => true, + getDelivery: () => "queue", + onQueue: (draft) => { + queued.push(draft) + }, + }) + + await submit.handleSubmit(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true })) + await Bun.sleep(0) + + expect(queued).toHaveLength(0) + expect(sentPrompts).toEqual(["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/repo/main"]) + expect(promptInputs[0]).toMatchObject({ sessionID: "session-1", delivery: "steer" }) + }) +}) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index c82c3a439939..41089f3b71ed 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -31,6 +31,8 @@ type PendingPrompt = { const pending = new Map() +export type FollowupDelivery = "queue" | "steer" + export type FollowupDraft = { sessionID: string sessionDirectory: string @@ -39,6 +41,7 @@ export type FollowupDraft = { agent: string model: { providerID: string; modelID: string } variant?: string + delivery?: FollowupDelivery } type FollowupSendInput = { @@ -46,6 +49,7 @@ type FollowupSendInput = { serverSync: ServerSync sync: DirectorySync draft: FollowupDraft + delivery?: FollowupDelivery messageID?: string optimisticBusy?: boolean before?: () => Promise | boolean @@ -171,6 +175,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { agent: input.draft.agent, model: input.draft.model, variant: input.draft.variant, + delivery: input.delivery ?? input.draft.delivery ?? "queue", legacyParts: requestParts, text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), files: requestParts.flatMap((part) => { @@ -225,6 +230,7 @@ type PromptSubmitInput = { newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void shouldQueue?: Accessor + getDelivery?: () => FollowupDelivery onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void @@ -318,6 +324,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() + // Codex-like delivery: Enter follows the follow-up setting (queue by default), + // Ctrl+Enter always steers the running session. + const keySteer = event instanceof KeyboardEvent && event.key === "Enter" && event.ctrlKey + const delivery = keySteer ? "steer" : (input.getDelivery?.() ?? "queue") + const target = prompt.capture() const submission = createPromptSubmissionState({ target, @@ -454,6 +465,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { agent, model, variant, + delivery, } const clearInput = () => { @@ -479,7 +491,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } - if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + if (!isNewSession && mode === "normal" && delivery === "queue" && input.shouldQueue?.()) { input.onQueue?.(draft) clearContext(submission.target()) clearInput() diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8adaa1f0fdf9..1fc0dac7e9a7 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -216,6 +216,11 @@ export const SettingsGeneral: Component = () => { })), ) + const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [ + { value: "queue", label: language.t("settings.general.row.followup.option.queue") }, + { value: "steer", label: language.t("settings.general.row.followup.option.steer") }, + ]) + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const mono = () => monoInput(settings.appearance.font()) @@ -347,6 +352,23 @@ export const SettingsGeneral: Component = () => { /> + +