diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33..45a6309cc12 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + autoSendStashedPrompts: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e92ecd497e3..c0be1904617 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -64,6 +64,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { shouldAutoSendStashedPrompt } from "./composerStashAutoSend"; import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; @@ -973,6 +974,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const [isAutoSendPending, setIsAutoSendPending] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ key: 0, active: false, @@ -998,6 +1000,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); + /** + * Whether the thread was mid-turn at the last auto-send evaluation. Starts + * false so the first pass only records the state it found — opening a + * thread that is already idle must not count as a turn having just ended. + */ + const wasThreadWorkingRef = useRef(false); /** * Snapshots currently being encoded, keyed by target+prompt+image ids. * Keyed rather than boolean so a genuinely different prompt (or a different @@ -2289,6 +2297,64 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalOpen, ]); + // ------------------------------------------------------------------ + // Prompt stash: auto-send + // ------------------------------------------------------------------ + // Opt-in. With it on, each finished turn takes the oldest stashed prompt + // and sends it, so prompts written while the agent was busy go out in the + // order they were stashed — one per turn, never in a batch. + const isThreadWorking = phase === "running" || isSendBusy || isConnecting; + // Mirrors the ⌘S gate, plus the states that refuse a plain send. + const isAutoSendBlocked = + isSendDisabled || + noProviderAvailable || + projectSelectionRequired || + environmentUnavailable !== null || + isComposerApprovalState || + pendingUserInputs.length > 0 || + activePendingProgress !== null || + showPlanFollowUpPrompt; + + useEffect(() => { + const wasWorking = wasThreadWorkingRef.current; + wasThreadWorkingRef.current = isThreadWorking; + if ( + !shouldAutoSendStashedPrompt({ + enabled: settings.autoSendStashedPrompts, + hasStashedPrompt: stashQueue.length > 0, + wasWorking, + isWorking: isThreadWorking, + phase, + hasComposerContent: composerSendState.hasSendableContent, + sendBlocked: isAutoSendBlocked, + }) + ) { + return; + } + const oldestEntry = stashQueue.at(-1); + if (!oldestEntry) return; + restoreStashEntry(oldestEntry); + setIsAutoSendPending(true); + }, [ + composerSendState.hasSendableContent, + isAutoSendBlocked, + isThreadWorking, + phase, + restoreStashEntry, + settings.autoSendStashedPrompts, + stashQueue, + ]); + + // The send is deferred by a commit: `restoreStashEntry` writes through the + // draft store, and the image ref `onSend` reads is only synced from it in + // an effect. Sending in the same pass would ship the prompt without its + // attachments. + useEffect(() => { + if (!isAutoSendPending) return; + setIsAutoSendPending(false); + submitComposer(); + }, [isAutoSendPending, submitComposer]); + // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ diff --git a/apps/web/src/components/chat/composerStashAutoSend.test.ts b/apps/web/src/components/chat/composerStashAutoSend.test.ts new file mode 100644 index 00000000000..d5e9e161199 --- /dev/null +++ b/apps/web/src/components/chat/composerStashAutoSend.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldAutoSendStashedPrompt } from "./composerStashAutoSend"; + +const settledTurn = { + enabled: true, + hasStashedPrompt: true, + wasWorking: true, + isWorking: false, + phase: "ready", + hasComposerContent: false, + sendBlocked: false, +} as const; + +describe("shouldAutoSendStashedPrompt", () => { + it("sends the next stashed prompt once the turn settles", () => { + expect(shouldAutoSendStashedPrompt(settledTurn)).toBe(true); + }); + + it("stays parked while the setting is off", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, enabled: false })).toBe(false); + }); + + it("does nothing with an empty stash", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, hasStashedPrompt: false })).toBe(false); + }); + + it("ignores a thread that was already idle", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, wasWorking: false })).toBe(false); + }); + + it("waits until the thread stops working", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, isWorking: true, phase: "running" })).toBe( + false, + ); + }); + + it("holds the stash when the turn was interrupted or failed", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, phase: "disconnected" })).toBe(false); + }); + + it("yields to a prompt the user is writing", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, hasComposerContent: true })).toBe(false); + }); + + it("holds while something else owns the composer", () => { + expect(shouldAutoSendStashedPrompt({ ...settledTurn, sendBlocked: true })).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/composerStashAutoSend.ts b/apps/web/src/components/chat/composerStashAutoSend.ts new file mode 100644 index 00000000000..313ad258457 --- /dev/null +++ b/apps/web/src/components/chat/composerStashAutoSend.ts @@ -0,0 +1,38 @@ +import { type SessionPhase } from "../../types"; + +/** + * Whether a turn that just settled should pull the next stashed prompt into + * the composer and send it (Settings → General → "Stashed prompts"). + * + * Edge-triggered on the turn ending rather than level-triggered on an idle + * thread: prompts stashed while nothing is running — or sitting there when + * the setting is switched on, or when the thread is opened — must stay put. + * Otherwise merely looking at an idle thread would fire one off. + */ +export function shouldAutoSendStashedPrompt(input: { + /** The `autoSendStashedPrompts` setting. */ + enabled: boolean; + hasStashedPrompt: boolean; + /** Whether the thread was mid-turn at the previous evaluation. */ + wasWorking: boolean; + isWorking: boolean; + /** + * Only a turn that ran to completion ("ready") qualifies. An interrupted or + * failed one settles as "disconnected", and carrying on from where the user + * hit stop is the opposite of what they asked for. + */ + phase: SessionPhase; + /** The user has their own content in the composer; theirs wins. */ + hasComposerContent: boolean; + /** + * A plain send is unavailable — an approval, a plan question, or a follow-up + * prompt owns the composer. What the user cannot send by hand right now, the + * stash must not send for them. + */ + sendBlocked: boolean; +}): boolean { + if (!input.enabled || !input.hasStashedPrompt) return false; + if (!input.wasWorking || input.isWorking) return false; + if (input.phase !== "ready") return false; + return !input.hasComposerContent && !input.sendBlocked; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ac4bba66e..a90528d918d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -592,6 +592,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.autoSendStashedPrompts !== DEFAULT_UNIFIED_SETTINGS.autoSendStashedPrompts + ? ["Stashed prompts"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -622,6 +625,7 @@ export function useSettingsRestore(onRestored?: () => void) { isTextGenerationModelDirty, isBackgroundActivityDirty, settings.autoOpenPlanSidebar, + settings.autoSendStashedPrompts, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -660,6 +664,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + autoSendStashedPrompts: DEFAULT_UNIFIED_SETTINGS.autoSendStashedPrompts, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1436,6 +1441,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + autoSendStashedPrompts: DEFAULT_UNIFIED_SETTINGS.autoSendStashedPrompts, + }) + } + /> + ) : null + } + control={ + + updateSettings({ autoSendStashedPrompts: Boolean(checked) }) + } + aria-label="Send stashed prompts automatically" + /> + } + /> + { }); }); +describe("ClientSettings stash auto-send", () => { + it("leaves stashed prompts parked until the user opts in", () => { + expect(decodeClientSettings({}).autoSendStashedPrompts).toBe(false); + expect(decodeClientSettingsPatch({ autoSendStashedPrompts: true }).autoSendStashedPrompts).toBe( + true, + ); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5..df4ddfe2af8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -64,6 +64,9 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Off by default: a stashed prompt is parked, not queued, until the user + // says otherwise. + autoSendStashedPrompts: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -675,6 +678,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + autoSendStashedPrompts: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),