Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
autoSendStashedPrompts: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -973,6 +974,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
const [isAutoSendPending, setIsAutoSendPending] = useState(false);
const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
key: 0,
active: false,
Expand All @@ -998,6 +1000,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const dragDepthRef = useRef(0);
const stashPulseKeyRef = useRef(0);
const stashPulseTimeoutRef = useRef<number | null>(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
Expand Down Expand Up @@ -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
// ------------------------------------------------------------------
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/chat/composerStashAutoSend.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions apps/web/src/components/chat/composerStashAutoSend.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -622,6 +625,7 @@ export function useSettingsRestore(onRestored?: () => void) {
isTextGenerationModelDirty,
isBackgroundActivityDirty,
settings.autoOpenPlanSidebar,
settings.autoSendStashedPrompts,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1436,6 +1441,32 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("stashed-prompts")}
description="Send the oldest stashed prompt automatically when the open thread finishes a turn."
resetAction={
settings.autoSendStashedPrompts !== DEFAULT_UNIFIED_SETTINGS.autoSendStashedPrompts ? (
<SettingResetButton
label="stashed prompts"
onClick={() =>
updateSettings({
autoSendStashedPrompts: DEFAULT_UNIFIED_SETTINGS.autoSendStashedPrompts,
})
}
/>
) : null
}
control={
<Switch
checked={settings.autoSendStashedPrompts}
onCheckedChange={(checked) =>
updateSettings({ autoSendStashedPrompts: Boolean(checked) })
}
aria-label="Send stashed prompts automatically"
/>
}
/>

<SettingsRow
{...searchableSetting("new-threads")}
description="Pick the default workspace mode for newly created draft threads."
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Auto-open task panel",
to: "/settings/general",
},
{
id: "stashed-prompts",
title: "Stashed prompts",
to: "/settings/general",
},
{
id: "new-threads",
title: "New threads",
Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ describe("ClientSettings sidebar v2", () => {
});
});

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({});
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
Loading