diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d40953ff2f9..fddc9f3bc9f4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -29,6 +29,7 @@ import { ImageAttachmentPart, AgentPart, FileAttachmentPart, + ContextItem, } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" @@ -46,6 +47,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" +import { useSettings } from "@/context/settings" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" @@ -168,10 +170,11 @@ export interface PromptInputProps { ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + edit?: { id: string; prompt: Prompt; context: ContextItem[] } onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void + shouldQueue?: (editID?: string) => boolean + onQueue?: (draft: FollowupDraft, editID?: string) => void + onEditLastQueued?: () => boolean onAbort?: () => void onSubmit?: () => void } @@ -206,6 +209,7 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() + const settings = useSettings() const sync = useSync() const files = useFile() @@ -354,6 +358,7 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + editID: string | null variantOpen: boolean }>({ popover: null, @@ -363,6 +368,7 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, + editID: null, variantOpen: false, }) const [picker, setPicker] = createStore({ @@ -392,7 +398,18 @@ export const PromptInput: Component = (props) => { .join("") return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 }) + const submitDisabled = createMemo(() => { + if (!working() && blank()) return true + if (working() && !blank() && !props.shouldQueue?.(store.editID ?? undefined)) return true + return false + }) const stopping = createMemo(() => working() && blank()) + const queueModeLabel = createMemo(() => { + if (!props.shouldQueue) return null + const mode = settings.general.followup() + return language.t(`settings.general.row.followup.option.${mode === "waitingSteer" ? "wrap" : mode === "queue" ? "queue" : "steer"}` as const) + }) + const tip = () => { if (stopping()) { return ( @@ -403,10 +420,16 @@ export const PromptInput: Component = (props) => { ) } + const label = queueModeLabel() return ( -
- {language.t("prompt.action.send")} - +
+
+ {label ? label : language.t("prompt.action.send")} + +
+ + {language.t("settings.general.row.followup.title")} +
) } @@ -972,6 +995,7 @@ export const PromptInput: Component = (props) => { if (shouldReset) { closePopover() resetHistoryNavigation() + setStore("editID", null) if (prompt.dirty()) { mirror.input = true prompt.set(DEFAULT_PROMPT, 0) @@ -1118,6 +1142,7 @@ export const PromptInput: Component = (props) => { setStore("popover", null) setStore("historyIndex", -1) setStore("savedPrompt", null) + setStore("editID", edit.id) prompt.set(edit.prompt, promptLength(edit.prompt)) requestAnimationFrame(() => { editorRef.focus() @@ -1126,7 +1151,6 @@ export const PromptInput: Component = (props) => { }) props.onEditLoaded?.() }, - { defer: true }, ), ) @@ -1198,14 +1222,14 @@ export const PromptInput: Component = (props) => { queueScroll, promptLength, addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), + resetHistoryNavigation, + setMode, + setPopover: closePopover, + editID: () => store.editID, + clearEditID: () => setStore("editID", null), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, + shouldQueue: (editID) => props.shouldQueue?.(editID) ?? false, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, @@ -1346,6 +1370,12 @@ export const PromptInput: Component = (props) => { .map((part) => ("content" in part ? part.content : "")) .join("") const direction = event.key === "ArrowUp" ? "up" : "down" + + if (direction === "up" && textContent === "" && !store.editID && props.onEditLastQueued?.()) { + event.preventDefault() + return + } + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return if (navigateHistory(direction)) { event.preventDefault() @@ -1357,18 +1387,7 @@ export const PromptInput: Component = (props) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } + if (submitDisabled()) return void handleSubmit(event) } } @@ -1660,6 +1679,11 @@ export const PromptInput: Component = (props) => {
+ + + {queueModeLabel()} + + = (props) => { />
+ + + {queueModeLabel()} + + { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -292,6 +293,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -323,6 +325,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, onSubmit: () => undefined, }) @@ -355,6 +358,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -367,4 +371,103 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) + + test("queues followup and clears edit id when in normal mode and shouldQueue is true", async () => { + params = { id: "session-1" } + + let queuedDraft: any = undefined + let queuedEditID: any = undefined + let cleared = false + + const submit = 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, + editID: () => "test-edit-id", + clearEditID: () => { cleared = true }, + shouldQueue: () => true, + onQueue: (draft, editID) => { + queuedDraft = draft + queuedEditID = editID + }, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(queuedDraft).toBeDefined() + expect(queuedEditID).toBe("test-edit-id") + expect(cleared).toBe(true) + }) + + test("sendFollowupDraft sets session_status accurately for followupModes", async () => { + const { sendFollowupDraft } = await import("./submit") + let syncData: any = undefined + + const client = clientFor("/repo/worktree-a") + const sync = { + set: (key: string, id: string, data: any) => { + if (key === "session_status") { + syncData = data + } + }, + session: { + optimistic: { remove: () => {}, add: () => {} } + } + } as any + const globalSync = { + child: () => [undefined, sync.set] + } as any + + await sendFollowupDraft({ + client: client as any, + serverSync: globalSync, + sync, + draft: { + sessionID: "session-1", + sessionDirectory: "/repo/worktree-a", + prompt: [{ type: "text", content: "test", start: 0, end: 4 }], + context: [], + agent: "build", + model: { providerID: "provider", modelID: "model" }, + isSteer: true, + }, + optimisticBusy: true, + before: () => true, + }) + + expect(syncData).toEqual({ type: "haltingSteer" }) + + await sendFollowupDraft({ + client: client as any, + serverSync: globalSync, + sync, + draft: { + sessionID: "session-1", + sessionDirectory: "/repo/worktree-a", + prompt: [{ type: "text", content: "test", start: 0, end: 4 }], + context: [], + agent: "build", + model: { providerID: "provider", modelID: "model" }, + followupMode: "waitingSteer", + }, + optimisticBusy: true, + before: () => true, + }) + + expect(syncData).toEqual({ type: "waitingSteer" }) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a95f0a603076..0597eb5cbfdf 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -37,6 +37,8 @@ export type FollowupDraft = { agent: string model: { providerID: string; modelID: string } variant?: string + isSteer?: boolean + followupMode?: string } type FollowupSendInput = { @@ -60,7 +62,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) { const setBusy = () => { if (!input.optimisticBusy) return - setStore("session_status", input.draft.sessionID, { type: "busy" }) + const type = input.draft.isSteer ? "haltingSteer" : input.draft.followupMode === "waitingSteer" ? "waitingSteer" : "busy" + setStore("session_status", input.draft.sessionID, { type }) } const setIdle = () => { @@ -162,6 +165,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) { messageID, parts: requestParts, variant: input.draft.variant, + isSteer: input.draft.isSteer, + followupMode: input.draft.followupMode as any, }) return true } catch (err) { @@ -188,10 +193,12 @@ type PromptSubmitInput = { resetHistoryNavigation: () => void setMode: (mode: "normal" | "shell") => void setPopover: (popover: "at" | "slash" | null) => void + editID?: Accessor + clearEditID: () => void newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void - shouldQueue?: Accessor - onQueue?: (draft: FollowupDraft) => void + shouldQueue?: (editID?: string) => boolean + onQueue?: (draft: FollowupDraft, editID?: string) => void onAbort?: () => void onSubmit?: () => void } @@ -425,6 +432,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { prompt.reset() input.setMode("normal") input.setPopover(null) + input.clearEditID() } const restoreInput = () => { @@ -440,11 +448,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) - clearContext() - clearInput() - return + if (!isNewSession && mode === "normal") { + if (input.shouldQueue?.(input.editID?.() ?? undefined)) { + input.onQueue?.(draft, input.editID?.() ?? undefined) + clearContext() + clearInput() + return + } + + // If we shouldn't queue but the agent is busy, we cannot submit. + // This happens if the queue is full (e.g. max 1 message in steer/wrap mode). + // We must block the submission entirely. + const status = sync().data.session_status?.[session.id]?.type ?? "idle" + const isBusy = status !== "idle" + if (isBusy) { + return + } } input.onSubmit?.() diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 1be3ffc51318..00602d972c36 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -268,6 +268,30 @@ export const SettingsGeneral: Component = () => { /> + +