Skip to content
Merged
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
24 changes: 23 additions & 1 deletion src/client/app/settings/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"
import { Monitor, Moon, Sun } from "lucide-react"
import { ANALYTICS_STATIC_EVENT_NAMES, ANALYTICS_STATIC_PROPERTY_NAMES } from "../../../shared/analytics"
import type { EditorPreset } from "../../../shared/protocol"
import { DEFAULT_NEW_PROJECTS_DIRECTORY } from "../../../shared/types"
import { DEFAULT_NEW_PROJECTS_DIRECTORY, type SubmitWhileRunning } from "../../../shared/types"
import { EDITOR_OPTIONS, EditorIcon } from "../../components/editor-icons"
import { Button } from "../../components/ui/button"
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "../../components/ui/dialog"
Expand Down Expand Up @@ -86,6 +86,7 @@ export function GeneralSection({
const [editorCommandDraft, setEditorCommandDraft] = useState(editorCommandTemplate)
const newProjectsDirectory = appSettings?.newProjectsDirectory ?? DEFAULT_NEW_PROJECTS_DIRECTORY
const [newProjectsDirectoryDraft, setNewProjectsDirectoryDraft] = useState(newProjectsDirectory)
const submitWhileRunning = appSettings?.submitWhileRunning ?? "queue"
const transcriptWindow = appSettings?.transcript?.windowAssistantMessages ?? DEFAULT_TRANSCRIPT_WINDOW_ASSISTANT_MESSAGES
const [transcriptWindowDraft, setTranscriptWindowDraft] = useState(String(transcriptWindow))
const [appSettingsError, setAppSettingsError] = useState<string | null>(null)
Expand Down Expand Up @@ -320,6 +321,27 @@ export function GeneralSection({
</Select>
</SettingsRow>

<SettingsRow def={SETTINGS_ROWS.submitWhileRunning}>
<Select
value={submitWhileRunning}
onValueChange={(value) => {
void handleWriteAppSettings({ submitWhileRunning: value as SubmitWhileRunning }).catch((error) => {
setAppSettingsError(error instanceof Error ? error.message : "Unable to save composer settings.")
})
}}
>
<SelectTrigger className="min-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="queue">Queue message</SelectItem>
<SelectItem value="steer">Steer now</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingsRow>

<SettingsRow def={SETTINGS_ROWS.defaultEditor} alignStart>
<Select
value={editorPreset}
Expand Down
6 changes: 6 additions & 0 deletions src/client/app/settings/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export const SETTINGS_ROWS = defineRows({
description: "The bundled sound used for chat notification playback and previews",
keywords: ["notifications", "audio"],
},
submitWhileRunning: {
sectionId: "general",
title: "Enter While Running",
description: "What Enter does while an agent is working. ⌘Enter always does the other one",
keywords: ["queue", "steer", "interrupt", "enter", "send", "composer"],
},
defaultEditor: {
sectionId: "general",
title: "Default Editor",
Expand Down
5 changes: 4 additions & 1 deletion src/client/app/useSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function useSendMessage(params: {

const handleSend = useCallback(async (
content: string,
options?: { provider?: AgentProvider; model?: string; modelOptions?: ModelOptions; planMode?: boolean; autoPlan?: boolean; attachments?: ChatAttachment[] }
options?: { provider?: AgentProvider; model?: string; modelOptions?: ModelOptions; planMode?: boolean; autoPlan?: boolean; attachments?: ChatAttachment[]; steer?: boolean }
) => {
const { isProcessing, optimisticUserPrompts, serverTranscriptEntries, selectedProjectId, fallbackLocalProjectPath } = sendContextRef.current
const attachments = options?.attachments ?? []
Expand All @@ -77,6 +77,9 @@ export function useSendMessage(params: {
modelOptions: options?.modelOptions,
planMode: options?.planMode,
autoPlan: options?.autoPlan,
// Only meaningful here: off a running turn there is nothing to
// interrupt, and the message starts immediately either way.
steer: options?.steer,
})
setCommandError(null)
return
Expand Down
20 changes: 14 additions & 6 deletions src/client/components/chat-ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { abbreviatePathHead, formatPathWithTilde } from "../../lib/pathUtils"
import { copyTextToClipboard } from "../../lib/clipboard"
import { buildUploadErrorReport, simpleUploadError, type UploadErrorReport } from "../../lib/uploadError"
import { useUnauthenticatedHarnesses } from "../../stores/providerAuthStore"
import { useAppSettingsStore } from "../../stores/appSettingsStore"
import { shouldSteerSubmit } from "../../../shared/submit-mode"
import { SignInDialog } from "../auth/SignInDialog"
import { ChatPreferenceControls } from "./ChatPreferenceControls"
import { ContextWindowMeter } from "./ContextWindowMeter"
Expand Down Expand Up @@ -168,7 +170,7 @@ interface ComposerAttachment extends ChatAttachment {
interface Props {
onSubmit: (
value: string,
options?: { provider?: AgentProvider; model?: string; modelOptions?: ModelOptions; planMode?: boolean; autoPlan?: boolean; attachments?: ChatAttachment[] }
options?: { provider?: AgentProvider; model?: string; modelOptions?: ModelOptions; planMode?: boolean; autoPlan?: boolean; attachments?: ChatAttachment[]; steer?: boolean }
) => Promise<void>
onLayoutChange?: () => void
onCancel?: () => void
Expand Down Expand Up @@ -244,6 +246,8 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
const { composerChatId, providerSwitchPending, selectedProvider } = composer
const providerPrefs = composer.effectiveState
const showModePicker = composer.supportsPlanMode
// What Enter does while a turn is running; ⌘/Ctrl+Enter does the other.
const submitWhileRunning = useAppSettingsStore((store) => store.settings?.submitWhileRunning) ?? "queue"
// Switching to a harness that isn't signed in is blocked: the pick is
// stashed here, a sign-in dialog opens, and the switch applies
// automatically once the auth store reports the service signed in.
Expand Down Expand Up @@ -684,7 +688,7 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
}, [])

/** The composer's current prefs, the way a send carries them. */
function buildSubmitOptions(attachmentsForSubmit: ChatAttachment[]) {
function buildSubmitOptions(attachmentsForSubmit: ChatAttachment[], steer = shouldSteerSubmit(submitWhileRunning, false)) {
let modelOptions: ModelOptions
if (providerPrefs.provider === "claude") {
modelOptions = { claude: { ...providerPrefs.modelOptions } }
Expand All @@ -696,6 +700,7 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
modelOptions = { codex: { ...providerPrefs.modelOptions } }
}
return {
steer,
provider: selectedProvider,
model: providerPrefs.model,
modelOptions,
Expand All @@ -705,15 +710,18 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
}
}

async function handleSubmit() {
async function handleSubmit(options?: { withModifier?: boolean }) {
if (!canSubmit || hasPendingUploads) return

const nextValue = value
const previousAttachments = attachmentsRef.current
const previousSelectedAttachmentId = selectedAttachmentId
const previousUploadError = uploadError
const attachmentsForSubmit = uploadedAttachments.map(({ previewUrl: _previewUrl, status: _status, ...attachment }) => attachment)
const submitOptions = buildSubmitOptions(attachmentsForSubmit)
const submitOptions = buildSubmitOptions(
attachmentsForSubmit,
shouldSteerSubmit(submitWhileRunning, options?.withModifier === true)
)
setValue("")
if (chatId) clearDraft(chatId)
if (textareaRef.current) textareaRef.current.style.height = "auto"
Expand Down Expand Up @@ -833,7 +841,7 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
if (event.key === "Enter" && !event.shiftKey && !isTouchDevice && !disabled && canSubmit && !hasPendingUploads) {
event.preventDefault()
void handleSubmit()
void handleSubmit({ withModifier: event.metaKey || event.ctrlKey })
}
}

Expand Down Expand Up @@ -1061,7 +1069,7 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(function ChatInput({
// cancel, so a file-only message queues instead of stopping
// the running turn.
if (!disabled && canSubmit && !hasPendingUploads) {
void handleSubmit()
void handleSubmit({ withModifier: event.metaKey || event.ctrlKey })
} else if (canCancel) {
onCancel?.()
}
Expand Down
98 changes: 98 additions & 0 deletions src/server/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,104 @@ describe("AgentCoordinator claude integration", () => {
events.close()
})

test("enqueue with steer goes through the same path as Send now on a queued message", async () => {
const events = new AsyncEventQueue<any>()
const prompts: string[] = []
const store = createFakeStore()
const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
startClaudeSession: async () => ({
provider: "claude",
stream: events,
getAccountInfo: async () => null,
interrupt: async () => {},
close: () => {},
setModel: async () => {},
setPermissionMode: async () => {},
sendPrompt: async (content: string) => {
prompts.push(content)
},
}),
})

await coordinator.send({
type: "chat.send",
chatId: "chat-1",
provider: "claude",
content: "first prompt",
model: "claude-opus-4-1",
})
await coordinator.enqueue({
type: "message.enqueue",
chatId: "chat-1",
content: "actually, do this instead",
steer: true,
})

// Interrupted and re-prompted straight away, with the steer block —
// exactly what steer() does, because it is steer().
expect(prompts).toHaveLength(2)
expect(prompts[1]).toContain("actually, do this instead")
expect(prompts[1]).toContain("<system-message>")
expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
expect(store.getQueuedMessages()).toEqual([])

events.close()
})

test("enqueue with steer is not an error when the queue drained first", async () => {
// The race the flag exists for: the turn ends while the message is being
// queued, the drain starts it, and steer() finds nothing to steer. The
// message is running — which is what was asked for — so that is success.
const events = new AsyncEventQueue<any>()
const prompts: string[] = []
const store = createFakeStore()
// Simulate the drain: the message is gone by the time steer() looks.
const enqueueMessage = store.enqueueMessage.bind(store)
store.enqueueMessage = async (chatId: string, message: any) => {
const queued = await enqueueMessage(chatId, message)
await store.removeQueuedMessage(chatId, queued.id)
return queued
}
const coordinator = new AgentCoordinator({
store: store as never,
onStateChange: () => {},
startClaudeSession: async () => ({
provider: "claude",
stream: events,
getAccountInfo: async () => null,
interrupt: async () => {},
close: () => {},
setModel: async () => {},
setPermissionMode: async () => {},
sendPrompt: async (content: string) => {
prompts.push(content)
},
}),
})

await coordinator.send({
type: "chat.send",
chatId: "chat-1",
provider: "claude",
content: "first prompt",
model: "claude-opus-4-1",
})
await expect(coordinator.enqueue({
type: "message.enqueue",
chatId: "chat-1",
content: "late steer",
steer: true,
})).resolves.toEqual({ queuedMessageId: expect.any(String) })

// Nothing was double-sent and the running turn was left alone.
expect(prompts).toEqual(["first prompt"])
expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(false)

events.close()
})

test("escape mid-turn does not surface the SDK's interrupt error result", async () => {
const events = new AsyncEventQueue<any>()
const store = createFakeStore()
Expand Down
13 changes: 13 additions & 0 deletions src/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,19 @@ export class AgentCoordinator {
planMode: command.planMode,
autoPlan: command.autoPlan,
})
if (command.steer) {
// The same path as "Send now" on a queued message, so the two can't
// drift. One thing it has to absorb: the turn can end while the message
// was being queued, in which case the drain has already started it —
// the outcome steering wanted, just not by this call.
try {
await this.steer({ type: "message.steer", chatId: command.chatId, queuedMessageId: queuedMessage.id })
} catch (error) {
const drained = !this.store.getQueuedMessage(command.chatId, queuedMessage.id)
&& this.activeTurns.has(command.chatId)
if (!drained) throw error
}
}
return { queuedMessageId: queuedMessage.id }
}

Expand Down
38 changes: 38 additions & 0 deletions src/server/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function expectedSettingsSnapshot(filePath: string, overrides: Partial<AppSettin
theme: "system",
chatSoundPreference: "always",
chatSoundId: "funk",
submitWhileRunning: "queue",
terminal: {
scrollbackLines: 1_000,
minColumnWidth: 450,
Expand Down Expand Up @@ -281,6 +282,43 @@ describe("AppSettingsManager", () => {
manager.dispose()
})

test("persists the composer's queue-or-steer default, and ignores junk", async () => {
const filePath = await createTempFilePath()
const manager = new AppSettingsManager(filePath)
await manager.initialize()

expect(manager.getSnapshot().submitWhileRunning).toBe("queue")
expect((await manager.writePatch({ submitWhileRunning: "steer" })).submitWhileRunning).toBe("steer")

const payload = JSON.parse(await readFile(filePath, "utf8")) as { submitWhileRunning: string }
expect(payload.submitWhileRunning).toBe("steer")

// Anything unrecognised falls back to queueing rather than to the more
// disruptive action.
await writeFile(filePath, JSON.stringify({ submitWhileRunning: "yolo" }), "utf8")
await manager.reload()
expect(manager.getSnapshot().submitWhileRunning).toBe("queue")

manager.dispose()
})

test("does not rewrite a settings file that already carries the composer default", async () => {
// Every field the file payload has must be in the comparison too, or the
// file is rewritten on every launch for no change.
const filePath = await createTempFilePath()
const first = new AppSettingsManager(filePath)
await first.initialize()
await first.writePatch({ submitWhileRunning: "steer" })
first.dispose()
const written = await readFile(filePath, "utf8")

const second = new AppSettingsManager(filePath)
await second.initialize()
second.dispose()

expect(await readFile(filePath, "utf8")).toBe(written)
})

test("normalizes GPT-5.6 reasoning levels when settings are written", async () => {
const filePath = await createTempFilePath()
const manager = new AppSettingsManager(filePath)
Expand Down
Loading