Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
17 changes: 16 additions & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (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 (
Expand All @@ -293,6 +294,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
)
}
if (queueHint()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.hint.queueSteer")}</span>
</div>
)
}

return (
<div class="flex items-center gap-2">
Expand Down Expand Up @@ -1223,6 +1231,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
getDelivery: props.getDelivery,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
Expand Down Expand Up @@ -1584,7 +1593,13 @@ export const PromptInput: Component<PromptInputProps> = (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")
}
/>
</Tooltip>
</div>
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/prompt-input/contracts.ts
Original file line number Diff line number Diff line change
@@ -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<typeof usePrompt>

Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(["/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(["/repo/main"])
expect(promptInputs[0]).toMatchObject({ sessionID: "session-1", delivery: "steer" })
})
})
14 changes: 13 additions & 1 deletion packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type PendingPrompt = {

const pending = new Map<string, PendingPrompt>()

export type FollowupDelivery = "queue" | "steer"

export type FollowupDraft = {
sessionID: string
sessionDirectory: string
Expand All @@ -39,13 +41,15 @@ export type FollowupDraft = {
agent: string
model: { providerID: string; modelID: string }
variant?: string
delivery?: FollowupDelivery
}

type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
delivery?: FollowupDelivery
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -225,6 +230,7 @@ type PromptSubmitInput = {
newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
getDelivery?: () => FollowupDelivery
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -454,6 +465,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
agent,
model,
variant,
delivery,
}

const clearInput = () => {
Expand All @@ -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()
Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -347,6 +352,23 @@ export const SettingsGeneral: Component = () => {
/>
</SettingsRow>

<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((option) => option.value === settings.general.followup())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>

<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
Expand Down
28 changes: 28 additions & 0 deletions packages/app/src/components/settings-v2/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import "./settings-v2.css"

const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const followupOptions: ("queue" | "steer")[] = ["queue", "steer"]
const fontSettings = {
ui: {
action: "settings-ui-font",
Expand Down Expand Up @@ -243,6 +244,31 @@ const SoundSetting: Component<{
)
}

const FollowupSetting = () => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRowV2
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<SelectV2
appearance="inline"
data-action="settings-followup"
options={followupOptions}
placement="bottom-end"
gutter={6}
current={followupOptions.find((option) => option === settings.general.followup())}
label={(option) => {
if (option === "queue") return language.t("settings.general.row.followup.option.queue")
return language.t("settings.general.row.followup.option.steer")
}}
onSelect={(option) => option && settings.general.setFollowup(option)}
/>
</SettingsRowV2>
)
}

const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
Expand Down Expand Up @@ -333,6 +359,8 @@ export const SettingsGeneralV2: Component<{

<ShellSetting controller={shell} />

<FollowupSetting />

<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
Expand Down
14 changes: 3 additions & 11 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
followup: "queue",
showFileTree: false,
showNavigation: false,
showSearch: false,
Expand Down Expand Up @@ -350,11 +350,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})

createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})

return {
ready,
get current() {
Expand All @@ -369,12 +364,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
setStore("general", "followup", value)
},
showFileTree,
setShowFileTree(value: boolean) {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ export const dict = {
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.action.queue": "Queue",
"prompt.hint.queueSteer": "Enter to queue · Ctrl+Enter to steer",

"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
Expand Down
Loading
Loading