From 0d0d81b423cef9db8ec72e4e15f2680330afe982 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:31:18 +0000 Subject: [PATCH] feat(web): optionally auto-send the next stashed prompt when a turn ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt stash is a parking spot: prompts written while an agent is busy sit there until they are restored by hand. This adds an opt-in setting (Settings -> General -> "Stashed prompts", off by default) that drains the stash automatically instead — when the open thread finishes a turn, the oldest entry is restored into the composer and sent, so follow-ups go out in the order they were stashed, one per turn. The trigger is edge-triggered on the turn settling rather than level- triggered on an idle thread, so a stash that is merely sitting in an idle thread — or one that predates switching the setting on — stays put. It also holds back when the turn was interrupted or failed (the session settles as "disconnected" rather than "ready"), when the user has their own content in the composer, and when an approval, a plan question, or a plan follow-up owns the composer. --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 66 +++++++++++++++++++ .../chat/composerStashAutoSend.test.ts | 49 ++++++++++++++ .../components/chat/composerStashAutoSend.ts | 38 +++++++++++ .../components/settings/SettingsPanels.tsx | 31 +++++++++ .../src/components/settings/settingsSearch.ts | 5 ++ packages/contracts/src/settings.test.ts | 9 +++ packages/contracts/src/settings.ts | 4 ++ 8 files changed, 203 insertions(+) create mode 100644 apps/web/src/components/chat/composerStashAutoSend.test.ts create mode 100644 apps/web/src/components/chat/composerStashAutoSend.ts 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),