diff --git a/src/browser/App.tsx b/src/browser/App.tsx
index 1cca2d9c7c..576c510ef7 100644
--- a/src/browser/App.tsx
+++ b/src/browser/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useCallback, useRef, useState } from "react";
+import { useEffect, useCallback, useMemo, useRef, useState } from "react";
import { useRouter } from "./contexts/RouterContext";
import { useLocation, useNavigate } from "react-router-dom";
import "./styles/globals.css";
@@ -105,6 +105,15 @@ import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
import { prependInitialAppProxyBasePath } from "@/browser/utils/frontendBasePath";
import { WorkspaceActiveGoalsWarningToast } from "@/browser/components/ActiveGoalsWarningToast/ActiveGoalsWarningToast";
import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen";
+import { AgentProvider } from "@/browser/contexts/AgentContext";
+import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
+import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
+import { useScheduledPromptDispatcher } from "@/browser/features/ScheduledPrompts/useScheduledPromptDispatcher";
+import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
+import {
+ useAdditionalSystemContextHydrated,
+ useAdditionalSystemContextSnapshot,
+} from "@/browser/utils/additionalSystemContextStore";
function RootRouteShell(props: {
leftSidebarCollapsed: boolean;
@@ -133,6 +142,75 @@ function RootRouteShell(props: {
);
}
+function shouldRunScheduledPromptDispatcher(meta: FrontendWorkspaceMetadata | undefined): boolean {
+ if (!meta || meta.incompatibleRuntime || meta.transcriptOnly) {
+ return false;
+ }
+ return !(Boolean(meta.parentWorkspaceId) && meta.taskStatus === "queued");
+}
+
+function ActiveWorkspaceScheduledPromptDispatcher(props: {
+ workspaceId: string;
+ projectPath: string;
+ enabled: boolean;
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+function ActiveWorkspaceScheduledPromptDispatcherInner(props: {
+ workspaceId: string;
+ enabled: boolean;
+}) {
+ const { api } = useAPI();
+ const sendMessageOptions = useSendMessageOptions(props.workspaceId);
+ const additionalSystemContext = useAdditionalSystemContextSnapshot(props.workspaceId);
+ const additionalSystemContextHydrated = useAdditionalSystemContextHydrated(props.workspaceId);
+ const scheduledSendMessageOptions = useMemo(
+ () => ({
+ ...sendMessageOptions,
+ ...(additionalSystemContextHydrated
+ ? {
+ additionalSystemContext: additionalSystemContext.enabled
+ ? additionalSystemContext.content
+ : "",
+ }
+ : {}),
+ }),
+ [
+ additionalSystemContext.content,
+ additionalSystemContext.enabled,
+ additionalSystemContextHydrated,
+ sendMessageOptions,
+ ]
+ );
+
+ useScheduledPromptDispatcher({
+ api,
+ workspaceId: props.workspaceId,
+ sendMessageOptions: scheduledSendMessageOptions,
+ enabled: props.enabled,
+ });
+
+ return null;
+}
+
function AppInner() {
// Get workspace state from context
const {
@@ -226,6 +304,17 @@ function AppInner() {
}, [sidebarCollapsed]);
const creationProjectPath =
!selectedWorkspace && !currentWorkspaceId ? pendingNewWorkspaceProject : null;
+ const scheduledPromptWorkspaceId = selectedWorkspace?.workspaceId ?? currentWorkspaceId;
+ const scheduledPromptWorkspaceMeta = scheduledPromptWorkspaceId
+ ? workspaceMetadata.get(scheduledPromptWorkspaceId)
+ : undefined;
+ const scheduledPromptProjectPath =
+ selectedWorkspace?.workspaceId === scheduledPromptWorkspaceId
+ ? selectedWorkspace.projectPath
+ : scheduledPromptWorkspaceMeta?.projectPath;
+ const scheduledPromptDispatcherEnabled = shouldRunScheduledPromptDispatcher(
+ scheduledPromptWorkspaceMeta
+ );
// History navigation (back/forward)
const navigate = useNavigate();
@@ -1071,6 +1160,13 @@ function AppInner() {
return (
<>
+ {scheduledPromptWorkspaceId && scheduledPromptProjectPath ? (
+
+ ) : null}
void;
}) {
return render(
@@ -227,7 +228,11 @@ function renderAgentHarness(props: {
-
+
@@ -335,6 +340,56 @@ describe("AgentContext", () => {
});
});
+ test("disabled global listeners do not handle agent shortcuts", async () => {
+ const projectPath = "/tmp/project";
+ mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT];
+ window.localStorage.setItem(getAgentIdKey(GLOBAL_SCOPE_ID), JSON.stringify("exec"));
+
+ let contextValue: AgentContextValue | undefined;
+ let openPickerEvents = 0;
+ const handleOpenPicker = () => {
+ openPickerEvents += 1;
+ };
+ window.addEventListener(CUSTOM_EVENTS.OPEN_AGENT_PICKER, handleOpenPicker as EventListener);
+
+ try {
+ renderAgentHarness({
+ projectPath,
+ enableGlobalListeners: false,
+ onChange: (value) => (contextValue = value),
+ });
+
+ await waitFor(() => {
+ expect(contextValue?.agentId).toBe("exec");
+ expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec", "plan"]);
+ });
+
+ window.api = { platform: "darwin", versions: {} };
+
+ fireEvent.keyDown(window, {
+ key: "A",
+ ctrlKey: true,
+ metaKey: true,
+ shiftKey: true,
+ });
+ fireEvent.keyDown(window, {
+ key: ".",
+ code: "Period",
+ metaKey: true,
+ });
+
+ await Promise.resolve();
+
+ expect(contextValue?.agentId).toBe("exec");
+ expect(openPickerEvents).toBe(0);
+ } finally {
+ window.removeEventListener(
+ CUSTOM_EVENTS.OPEN_AGENT_PICKER,
+ handleOpenPicker as EventListener
+ );
+ }
+ });
+
test("cycle shortcut advances away from a custom auto agent", async () => {
const projectPath = "/tmp/project";
mockAgentDefinitions = [AUTO_PROJECT_AGENT, REVIEW_PROJECT_AGENT];
diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx
index 873ddec569..5035b1b5cd 100644
--- a/src/browser/contexts/AgentContext.tsx
+++ b/src/browser/contexts/AgentContext.tsx
@@ -56,6 +56,7 @@ type AgentProviderProps =
| {
workspaceId?: string;
projectPath?: string;
+ enableGlobalListeners?: boolean;
children: ReactNode;
};
@@ -78,6 +79,7 @@ export function AgentProvider(props: AgentProviderProps) {
function AgentProviderWithState(props: {
workspaceId?: string;
projectPath?: string;
+ enableGlobalListeners?: boolean;
children: ReactNode;
}) {
const { api } = useAPI();
@@ -297,6 +299,10 @@ function AgentProviderWithState(props: {
}, [effectiveAgentId, isCurrentAgentLocked, selectableAgents, setAgentId]);
useEffect(() => {
+ if (props.enableGlobalListeners === false) {
+ return;
+ }
+
const handleKeyDown = (e: KeyboardEvent) => {
if (matchesKeybind(e, KEYBINDS.TOGGLE_AGENT)) {
e.preventDefault();
@@ -315,9 +321,13 @@ function AgentProviderWithState(props: {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [cycleToNextAgent, isCurrentAgentLocked]);
+ }, [cycleToNextAgent, isCurrentAgentLocked, props.enableGlobalListeners]);
useEffect(() => {
+ if (props.enableGlobalListeners === false) {
+ return;
+ }
+
const handleRefreshRequested = () => {
void refresh();
};
@@ -325,7 +335,7 @@ function AgentProviderWithState(props: {
window.addEventListener(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED, handleRefreshRequested);
return () =>
window.removeEventListener(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED, handleRefreshRequested);
- }, [refresh]);
+ }, [props.enableGlobalListeners, refresh]);
const agentContextValue = useMemo(
() => ({
diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx
index b0314f57f5..ef5eb22ff8 100644
--- a/src/browser/contexts/ThinkingContext.test.tsx
+++ b/src/browser/contexts/ThinkingContext.test.tsx
@@ -565,4 +565,35 @@ describe("ThinkingContext", () => {
expect(view.getByTestId("thinking-project").textContent).toBe("low");
});
});
+
+ test("disabled global listeners do not cycle thinking via keybind", async () => {
+ const projectPath = "/Users/dev/my-project";
+
+ updatePersistedState(getModelKey(getProjectScopeId(projectPath)), "openai:gpt-4.1");
+
+ const ProjectChild: React.FC = () => {
+ const [thinkingLevel] = useThinkingLevel();
+ return {thinkingLevel}
;
+ };
+
+ const view = renderWithAPI(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(view.getByTestId("thinking-project").textContent).toBe("off");
+ });
+
+ act(() => {
+ window.dispatchEvent(
+ new window.KeyboardEvent("keydown", { key: "T", ctrlKey: true, shiftKey: true })
+ );
+ });
+
+ await Promise.resolve();
+
+ expect(view.getByTestId("thinking-project").textContent).toBe("off");
+ });
});
diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx
index 9104f6cc28..46490958f6 100644
--- a/src/browser/contexts/ThinkingContext.tsx
+++ b/src/browser/contexts/ThinkingContext.tsx
@@ -39,6 +39,7 @@ const ThinkingContext = createContext(undefined
interface ThinkingProviderProps {
workspaceId?: string; // Workspace-scoped storage (highest priority)
projectPath?: string; // Project-scoped storage (fallback if no workspaceId)
+ enableGlobalListeners?: boolean;
children: ReactNode;
}
@@ -176,6 +177,10 @@ export const ThinkingProvider: React.FC = (props) => {
// Implemented at the ThinkingProvider level so it works in both the workspace view
// and the "New Workspace" creation screen (which doesn't mount AIView).
useEffect(() => {
+ if (props.enableGlobalListeners === false) {
+ return;
+ }
+
const handleKeyDown = (e: KeyboardEvent) => {
if (!matchesKeybind(e, KEYBINDS.TOGGLE_THINKING)) {
return;
@@ -200,7 +205,15 @@ export const ThinkingProvider: React.FC = (props) => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [defaultModel, getMinimum, metadataSettings.model, scopeId, thinkingLevel, setThinkingLevel]);
+ }, [
+ defaultModel,
+ getMinimum,
+ metadataSettings.model,
+ props.enableGlobalListeners,
+ scopeId,
+ thinkingLevel,
+ setThinkingLevel,
+ ]);
// Memoize context value to prevent unnecessary re-renders of consumers.
const contextValue = useMemo(
diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx
index e8921801c5..a42042afdd 100644
--- a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx
+++ b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx
@@ -10,6 +10,7 @@
import React from "react";
import {
BugPlay,
+ Clock3,
ExternalLink,
Monitor,
Globe,
@@ -225,6 +226,13 @@ export function OutputTabLabel() {
return <>Output>;
}
+export const ScheduleTabLabel: React.FC = () => (
+
+
+ Schedule
+
+);
+
interface InstructionsTabLabelProps {
workspaceId: string;
}
diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts
index 105558a3ec..4b81c3c4d5 100644
--- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts
+++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts
@@ -53,6 +53,12 @@ const TAB_CONFIG_DEF = {
defaultOrder: 35,
paletteKeywords: ["goal", "target", "objective"],
},
+ schedule: {
+ name: "Schedule",
+ contentClassName: "overflow-y-auto p-0",
+ defaultOrder: 37,
+ paletteKeywords: ["schedule", "scheduled", "prompt", "queue", "timer"],
+ },
desktop: {
name: "Desktop",
contentClassName: "overflow-hidden p-0",
diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx
index 395b6e7168..f48f2a74d7 100644
--- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx
+++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx
@@ -22,6 +22,7 @@ import { DesktopPanel } from "@/browser/features/desktop/DesktopPanel";
import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab";
import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab";
import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab";
+import { ScheduledPromptsTab } from "@/browser/features/ScheduledPrompts/ScheduledPromptsTab";
import type { GoalSnapshot, GoalStatus } from "@/common/types/goal";
import type { ReviewNoteData } from "@/common/types/review";
import { BASE_TAB_IDS, TAB_CONFIG, type BaseTabType, type TabConfig } from "./tabConfig";
@@ -33,6 +34,7 @@ import {
InstructionsTabLabel,
OutputTabLabel,
ReviewTabLabel,
+ ScheduleTabLabel,
StatsTabLabel,
} from "./TabLabels";
@@ -157,6 +159,14 @@ const TAB_RENDERERS = {
),
},
+ schedule: {
+ Label: ScheduleTabLabel,
+ renderPanel: (ctx) => (
+
+
+
+ ),
+ },
desktop: {
Label: DesktopTabLabel,
renderPanel: (ctx) => (
diff --git a/src/browser/features/ScheduledPrompts/ScheduledPromptsTab.tsx b/src/browser/features/ScheduledPrompts/ScheduledPromptsTab.tsx
new file mode 100644
index 0000000000..04d769fd74
--- /dev/null
+++ b/src/browser/features/ScheduledPrompts/ScheduledPromptsTab.tsx
@@ -0,0 +1,211 @@
+import React, { useMemo, useState } from "react";
+import { Clock3, Play, Send, Trash2 } from "lucide-react";
+import { Button } from "@/browser/components/Button/Button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip";
+import { usePersistedState } from "@/browser/hooks/usePersistedState";
+import { formatTimestamp } from "@/browser/utils/ui/dateTime";
+import { getScheduledPromptsKey } from "@/common/constants/storage";
+import { cn } from "@/common/lib/utils";
+import type { QueueDispatchMode } from "@/browser/features/ChatInput/types";
+import {
+ canRunScheduledPromptNow,
+ createScheduledPrompt,
+ formatDateTimeLocalInput,
+ normalizeScheduledPrompts,
+ parseDateTimeLocalInput,
+ removeScheduledPrompt,
+ reschedulePromptNow,
+ type ScheduledPrompt,
+} from "./scheduledPrompts";
+
+interface ScheduledPromptsTabProps {
+ workspaceId: string;
+}
+
+const DEFAULT_DELAY_MS = 60 * 60 * 1000;
+
+export function ScheduledPromptsTab(props: ScheduledPromptsTabProps) {
+ const storageKey = getScheduledPromptsKey(props.workspaceId);
+ const [storedPrompts, setStoredPrompts] = usePersistedState(storageKey, [], {
+ listener: true,
+ });
+ const prompts = useMemo(() => normalizeScheduledPrompts(storedPrompts), [storedPrompts]);
+ const [content, setContent] = useState("");
+ const [runAtInput, setRunAtInput] = useState(() =>
+ formatDateTimeLocalInput(Date.now() + DEFAULT_DELAY_MS)
+ );
+ const [queueDispatchMode, setQueueDispatchMode] = useState("tool-end");
+
+ const runAt = parseDateTimeLocalInput(runAtInput);
+ const canSchedule = content.trim().length > 0 && runAt !== null && runAt > Date.now();
+
+ const addPrompt = () => {
+ if (!canSchedule || runAt === null) {
+ return;
+ }
+
+ const prompt = createScheduledPrompt({
+ content,
+ runAt,
+ queueDispatchMode,
+ });
+ setStoredPrompts((current) => [...normalizeScheduledPrompts(current), prompt]);
+ setContent("");
+ setRunAtInput(formatDateTimeLocalInput(runAt + DEFAULT_DELAY_MS));
+ };
+
+ const runPromptNow = (id: string) => {
+ setStoredPrompts((current) => reschedulePromptNow(normalizeScheduledPrompts(current), id));
+ };
+
+ const removePrompt = (id: string) => {
+ setStoredPrompts((current) => removeScheduledPrompt(normalizeScheduledPrompts(current), id));
+ };
+
+ return (
+
+
+
+
+
+ {prompts.length.toLocaleString()} scheduled prompt{prompts.length === 1 ? "" : "s"}
+
+ {prompts.length === 0 ? (
+
+ No scheduled prompts.
+
+ ) : (
+
+ {prompts.map((prompt) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+interface ScheduledPromptCardProps {
+ prompt: ScheduledPrompt;
+ onRunNow: (id: string) => void;
+ onRemove: (id: string) => void;
+}
+
+function ScheduledPromptCard(props: ScheduledPromptCardProps) {
+ const statusClassName =
+ props.prompt.status === "failed"
+ ? "text-error"
+ : props.prompt.status === "sent"
+ ? "text-success"
+ : props.prompt.status === "sending"
+ ? "text-accent"
+ : "text-muted";
+
+ return (
+
+
+
+
+ {props.prompt.status}
+
+
+ {formatTimestamp(props.prompt.runAt)}
+
+
+
+ {props.prompt.content}
+
+ {props.prompt.error && (
+
{props.prompt.error}
+ )}
+
+
+
+ {props.prompt.queueDispatchMode === "turn-end" ? "After turn" : "After step"}
+
+
+ {props.prompt.status !== "sent" && canRunScheduledPromptNow(props.prompt) && (
+
props.onRunNow(props.prompt.id)}
+ >
+
+
+ )}
+
props.onRemove(props.prompt.id)}>
+
+
+
+
+
+ );
+}
+
+interface ScheduledPromptIconButtonProps {
+ label: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}
+
+function ScheduledPromptIconButton(props: ScheduledPromptIconButtonProps) {
+ return (
+
+
+
+
+ {props.label}
+
+ );
+}
diff --git a/src/browser/features/ScheduledPrompts/scheduledPrompts.test.ts b/src/browser/features/ScheduledPrompts/scheduledPrompts.test.ts
new file mode 100644
index 0000000000..df92c80f7e
--- /dev/null
+++ b/src/browser/features/ScheduledPrompts/scheduledPrompts.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, test } from "bun:test";
+import {
+ canRunScheduledPromptNow,
+ createScheduledPrompt,
+ getDueScheduledPrompts,
+ getNextScheduledPromptRunAt,
+ markScheduledPromptFailed,
+ markScheduledPromptSending,
+ markScheduledPromptSent,
+ normalizeScheduledPrompts,
+ removeScheduledPrompt,
+ reschedulePromptNow,
+ type ScheduledPrompt,
+} from "./scheduledPrompts";
+
+const NOW = 1_700_000_000_000;
+
+function scheduledPrompt(overrides: Partial = {}): ScheduledPrompt {
+ return {
+ id: "prompt-1",
+ content: "Continue when quota resets",
+ runAt: NOW + 60_000,
+ createdAt: NOW,
+ updatedAt: NOW,
+ status: "scheduled",
+ queueDispatchMode: "tool-end",
+ ...overrides,
+ };
+}
+
+describe("scheduled prompt helpers", () => {
+ test("normalizes valid prompts and drops malformed entries", () => {
+ const prompts = normalizeScheduledPrompts(
+ [
+ scheduledPrompt({ id: "later", runAt: NOW + 120_000 }),
+ { id: "bad", content: "", runAt: NOW, createdAt: NOW, updatedAt: NOW },
+ scheduledPrompt({ id: "earlier", runAt: NOW + 30_000 }),
+ ],
+ NOW
+ );
+
+ expect(prompts.map((prompt) => prompt.id)).toEqual(["earlier", "later"]);
+ });
+
+ test("keeps in-progress sends in sending state when normalizing", () => {
+ const prompts = normalizeScheduledPrompts(
+ [
+ scheduledPrompt({
+ status: "sending",
+ updatedAt: NOW - 10 * 60_000,
+ error: "stale",
+ }),
+ ],
+ NOW
+ );
+
+ expect(prompts[0]?.status).toBe("sending");
+ expect(prompts[0]?.error).toBeUndefined();
+ });
+
+ test("finds due prompts and next scheduled time", () => {
+ const prompts = [
+ scheduledPrompt({ id: "due", runAt: NOW - 1 }),
+ scheduledPrompt({ id: "future", runAt: NOW + 5_000 }),
+ scheduledPrompt({ id: "failed", runAt: NOW - 2, status: "failed" }),
+ ];
+
+ expect(getDueScheduledPrompts(prompts, NOW).map((prompt) => prompt.id)).toEqual(["due"]);
+ expect(getNextScheduledPromptRunAt(prompts)).toBe(NOW - 1);
+ });
+
+ test("updates prompt lifecycle state", () => {
+ const prompt = createScheduledPrompt(
+ {
+ content: " run later ",
+ runAt: NOW + 1_000,
+ queueDispatchMode: "turn-end",
+ },
+ NOW,
+ "created"
+ );
+
+ expect(prompt).toMatchObject({
+ id: "created",
+ content: "run later",
+ status: "scheduled",
+ queueDispatchMode: "turn-end",
+ });
+
+ const sending = markScheduledPromptSending([prompt], "created", NOW + 1);
+ expect(sending[0]?.status).toBe("sending");
+
+ const failed = markScheduledPromptFailed(sending, "created", "No connection", NOW + 2);
+ expect(failed[0]).toMatchObject({ status: "failed", error: "No connection" });
+
+ const retried = reschedulePromptNow(failed, "created", NOW + 3);
+ expect(retried[0]).toMatchObject({ status: "scheduled", runAt: NOW + 3 });
+
+ const sent = markScheduledPromptSent(retried, "created", NOW + 4);
+ expect(sent[0]).toMatchObject({ status: "sent", sentAt: NOW + 4 });
+
+ expect(removeScheduledPrompt(sent, "created")).toEqual([]);
+ });
+
+ test("allows manual recovery for stale sending prompts without auto-rescheduling", () => {
+ expect(
+ canRunScheduledPromptNow(
+ scheduledPrompt({ status: "sending", updatedAt: NOW - 29 * 60_000 }),
+ NOW
+ )
+ ).toBe(false);
+ expect(
+ canRunScheduledPromptNow(
+ scheduledPrompt({ status: "sending", updatedAt: NOW - 31 * 60_000 }),
+ NOW
+ )
+ ).toBe(true);
+ });
+});
diff --git a/src/browser/features/ScheduledPrompts/scheduledPrompts.ts b/src/browser/features/ScheduledPrompts/scheduledPrompts.ts
new file mode 100644
index 0000000000..7d31cc5a25
--- /dev/null
+++ b/src/browser/features/ScheduledPrompts/scheduledPrompts.ts
@@ -0,0 +1,193 @@
+import type { QueueDispatchMode } from "@/browser/features/ChatInput/types";
+
+export type ScheduledPromptStatus = "scheduled" | "sending" | "sent" | "failed";
+
+export interface ScheduledPrompt {
+ id: string;
+ content: string;
+ runAt: number;
+ createdAt: number;
+ updatedAt: number;
+ status: ScheduledPromptStatus;
+ queueDispatchMode: QueueDispatchMode;
+ sentAt?: number;
+ error?: string;
+}
+
+export interface ScheduledPromptDraft {
+ content: string;
+ runAt: number;
+ queueDispatchMode: QueueDispatchMode;
+}
+
+const SENDING_MANUAL_RECOVERY_MS = 30 * 60 * 1000;
+
+function isQueueDispatchMode(value: unknown): value is QueueDispatchMode {
+ return value === "tool-end" || value === "turn-end";
+}
+
+function isScheduledPromptStatus(value: unknown): value is ScheduledPromptStatus {
+ return value === "scheduled" || value === "sending" || value === "sent" || value === "failed";
+}
+
+function fallbackId(now: number): string {
+ return `${now.toString(36)}-${Math.random().toString(36).slice(2)}`;
+}
+
+export function createScheduledPrompt(
+ draft: ScheduledPromptDraft,
+ now = Date.now(),
+ id: string = globalThis.crypto?.randomUUID?.() ?? fallbackId(now)
+): ScheduledPrompt {
+ return {
+ id,
+ content: draft.content.trim(),
+ runAt: draft.runAt,
+ createdAt: now,
+ updatedAt: now,
+ status: "scheduled",
+ queueDispatchMode: draft.queueDispatchMode,
+ };
+}
+
+export function normalizeScheduledPrompts(value: unknown, now = Date.now()): ScheduledPrompt[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const prompts: ScheduledPrompt[] = [];
+ for (const item of value) {
+ if (typeof item !== "object" || item === null) {
+ continue;
+ }
+
+ const raw = item as Record;
+ if (
+ typeof raw.id !== "string" ||
+ typeof raw.content !== "string" ||
+ raw.content.trim().length === 0 ||
+ typeof raw.runAt !== "number" ||
+ !Number.isFinite(raw.runAt) ||
+ typeof raw.createdAt !== "number" ||
+ typeof raw.updatedAt !== "number" ||
+ !isScheduledPromptStatus(raw.status)
+ ) {
+ continue;
+ }
+
+ prompts.push({
+ id: raw.id,
+ content: raw.content,
+ runAt: raw.runAt,
+ createdAt: raw.createdAt,
+ updatedAt: raw.updatedAt,
+ status: raw.status,
+ queueDispatchMode: isQueueDispatchMode(raw.queueDispatchMode)
+ ? raw.queueDispatchMode
+ : "tool-end",
+ ...(typeof raw.sentAt === "number" ? { sentAt: raw.sentAt } : {}),
+ ...(typeof raw.error === "string" && raw.status === "failed" ? { error: raw.error } : {}),
+ });
+ }
+
+ return prompts.sort((left, right) => {
+ if (left.status !== right.status) {
+ const rank: Record = {
+ sending: 0,
+ scheduled: 1,
+ failed: 2,
+ sent: 3,
+ };
+ return rank[left.status] - rank[right.status];
+ }
+ return left.runAt - right.runAt || left.createdAt - right.createdAt;
+ });
+}
+
+export function getDueScheduledPrompts(
+ prompts: readonly ScheduledPrompt[],
+ now = Date.now()
+): ScheduledPrompt[] {
+ return prompts.filter((prompt) => prompt.status === "scheduled" && prompt.runAt <= now);
+}
+
+export function getNextScheduledPromptRunAt(prompts: readonly ScheduledPrompt[]): number | null {
+ const scheduled = prompts.filter((prompt) => prompt.status === "scheduled");
+ if (scheduled.length === 0) {
+ return null;
+ }
+ return Math.min(...scheduled.map((prompt) => prompt.runAt));
+}
+
+export function canRunScheduledPromptNow(prompt: ScheduledPrompt, now = Date.now()): boolean {
+ return (
+ prompt.status === "scheduled" ||
+ prompt.status === "failed" ||
+ (prompt.status === "sending" && prompt.updatedAt <= now - SENDING_MANUAL_RECOVERY_MS)
+ );
+}
+
+export function markScheduledPromptSending(
+ prompts: readonly ScheduledPrompt[],
+ id: string,
+ now = Date.now()
+): ScheduledPrompt[] {
+ return prompts.map((prompt) =>
+ prompt.id === id ? { ...prompt, status: "sending", updatedAt: now, error: undefined } : prompt
+ );
+}
+
+export function markScheduledPromptSent(
+ prompts: readonly ScheduledPrompt[],
+ id: string,
+ now = Date.now()
+): ScheduledPrompt[] {
+ return prompts.map((prompt) =>
+ prompt.id === id
+ ? { ...prompt, status: "sent", sentAt: now, updatedAt: now, error: undefined }
+ : prompt
+ );
+}
+
+export function markScheduledPromptFailed(
+ prompts: readonly ScheduledPrompt[],
+ id: string,
+ error: string,
+ now = Date.now()
+): ScheduledPrompt[] {
+ return prompts.map((prompt) =>
+ prompt.id === id ? { ...prompt, status: "failed", error, updatedAt: now } : prompt
+ );
+}
+
+export function reschedulePromptNow(
+ prompts: readonly ScheduledPrompt[],
+ id: string,
+ now = Date.now()
+): ScheduledPrompt[] {
+ return prompts.map((prompt) =>
+ prompt.id === id
+ ? { ...prompt, status: "scheduled", runAt: now, updatedAt: now, error: undefined }
+ : prompt
+ );
+}
+
+export function removeScheduledPrompt(
+ prompts: readonly ScheduledPrompt[],
+ id: string
+): ScheduledPrompt[] {
+ return prompts.filter((prompt) => prompt.id !== id);
+}
+
+export function formatDateTimeLocalInput(timestamp: number): string {
+ const date = new Date(timestamp);
+ const pad = (value: number) => value.toString().padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
+ date.getHours()
+ )}:${pad(date.getMinutes())}`;
+}
+
+export function parseDateTimeLocalInput(value: string): number | null {
+ const timestamp = new Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : null;
+}
diff --git a/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.test.tsx b/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.test.tsx
new file mode 100644
index 0000000000..a18b739c4b
--- /dev/null
+++ b/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.test.tsx
@@ -0,0 +1,173 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { act, cleanup, render } from "@testing-library/react";
+import { GlobalWindow } from "happy-dom";
+
+import type { APIClient } from "@/browser/contexts/API";
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
+import { getScheduledPromptsKey } from "@/common/constants/storage";
+import type { SendMessageOptions } from "@/common/orpc/types";
+import { createScheduledPrompt, type ScheduledPrompt } from "./scheduledPrompts";
+import { useScheduledPromptDispatcher } from "./useScheduledPromptDispatcher";
+
+const WORKSPACE_ID = "workspace-scheduled-dispatcher";
+const NOW = 1_700_000_000_000;
+const SEND_OPTIONS: SendMessageOptions = {
+ agentId: "exec",
+ model: "openai:test",
+};
+
+function Dispatcher(props: { api: APIClient }) {
+ useScheduledPromptDispatcher({
+ api: props.api,
+ workspaceId: WORKSPACE_ID,
+ sendMessageOptions: SEND_OPTIONS,
+ enabled: true,
+ });
+ return null;
+}
+
+function writePrompts(prompts: ScheduledPrompt[]) {
+ updatePersistedState(getScheduledPromptsKey(WORKSPACE_ID), prompts);
+}
+
+async function waitForDispatcherTick() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+}
+
+describe("useScheduledPromptDispatcher", () => {
+ let originalWindow: typeof globalThis.window;
+ let originalDocument: typeof globalThis.document;
+ let originalLocalStorage: typeof globalThis.localStorage;
+ let originalCustomEvent: typeof globalThis.CustomEvent;
+ let originalStorageEvent: typeof globalThis.StorageEvent;
+
+ beforeEach(() => {
+ originalWindow = globalThis.window;
+ originalDocument = globalThis.document;
+ originalLocalStorage = globalThis.localStorage;
+ originalCustomEvent = globalThis.CustomEvent;
+ originalStorageEvent = globalThis.StorageEvent;
+
+ const dom = new GlobalWindow();
+ globalThis.window = dom as unknown as Window & typeof globalThis;
+ globalThis.document = dom.document as unknown as Document;
+ globalThis.localStorage = dom.localStorage as unknown as Storage;
+ globalThis.CustomEvent = dom.CustomEvent;
+ globalThis.StorageEvent = dom.StorageEvent;
+ globalThis.window.setTimeout = globalThis.setTimeout.bind(
+ globalThis
+ ) as typeof window.setTimeout;
+ globalThis.window.clearTimeout = globalThis.clearTimeout.bind(
+ globalThis
+ ) as typeof window.clearTimeout;
+ });
+
+ afterEach(() => {
+ cleanup();
+ mock.restore();
+ globalThis.window = originalWindow;
+ globalThis.document = originalDocument;
+ globalThis.localStorage = originalLocalStorage;
+ globalThis.CustomEvent = originalCustomEvent;
+ globalThis.StorageEvent = originalStorageEvent;
+ });
+
+ test("dispatches multiple due prompts sequentially", async () => {
+ const first = createScheduledPrompt(
+ { content: "first", runAt: NOW - 2, queueDispatchMode: "tool-end" },
+ NOW,
+ "first"
+ );
+ const second = createScheduledPrompt(
+ { content: "second", runAt: NOW - 1, queueDispatchMode: "turn-end" },
+ NOW,
+ "second"
+ );
+ writePrompts([second, first]);
+
+ const resolvers: Array<(value: { success: true; data: Record }) => void> = [];
+ const sendMessage = mock(() => {
+ return new Promise<{ success: true; data: Record }>((resolve) => {
+ resolvers.push(resolve);
+ });
+ });
+ const api = {
+ workspace: {
+ sendMessage,
+ },
+ } as unknown as APIClient;
+
+ render();
+
+ await waitForDispatcherTick();
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage.mock.calls[0]?.[0].message).toBe("first");
+ expect(sendMessage.mock.calls[0]?.[0].options.queueDispatchMode).toBe("tool-end");
+
+ await Promise.resolve();
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ resolvers[0]?.({ success: true, data: {} });
+ await Promise.resolve();
+ });
+
+ await waitForDispatcherTick();
+ expect(sendMessage).toHaveBeenCalledTimes(2);
+ expect(sendMessage.mock.calls[1]?.[0].message).toBe("second");
+ expect(sendMessage.mock.calls[1]?.[0].options.queueDispatchMode).toBe("turn-end");
+
+ await act(async () => {
+ resolvers[1]?.({ success: true, data: {} });
+ await Promise.resolve();
+ });
+ });
+
+ test("skips queued due prompts that were removed before dispatch", async () => {
+ const first = createScheduledPrompt(
+ { content: "first", runAt: NOW - 2, queueDispatchMode: "tool-end" },
+ NOW,
+ "first"
+ );
+ const second = createScheduledPrompt(
+ { content: "second", runAt: NOW - 1, queueDispatchMode: "turn-end" },
+ NOW,
+ "second"
+ );
+ writePrompts([first, second]);
+
+ let resolveFirst: ((value: { success: true; data: Record }) => void) | undefined;
+ const sendMessage = mock(() => {
+ return new Promise<{ success: true; data: Record }>((resolve) => {
+ resolveFirst = resolve;
+ });
+ });
+ const api = {
+ workspace: {
+ sendMessage,
+ },
+ } as unknown as APIClient;
+
+ render();
+
+ await waitForDispatcherTick();
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage.mock.calls[0]?.[0].message).toBe("first");
+
+ act(() => {
+ writePrompts([first]);
+ });
+
+ await act(async () => {
+ resolveFirst?.({ success: true, data: {} });
+ await Promise.resolve();
+ });
+
+ await Promise.resolve();
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.ts b/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.ts
new file mode 100644
index 0000000000..927676f5b1
--- /dev/null
+++ b/src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.ts
@@ -0,0 +1,174 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { APIClient } from "@/browser/contexts/API";
+import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState";
+import { getScheduledPromptsKey } from "@/common/constants/storage";
+import { prepareUserMessageForSend, type MuxMessageMetadata } from "@/common/types/message";
+import type { SendMessageOptions } from "@/common/orpc/types";
+import type { QueueDispatchMode } from "@/browser/features/ChatInput/types";
+import {
+ getDueScheduledPrompts,
+ getNextScheduledPromptRunAt,
+ markScheduledPromptFailed,
+ markScheduledPromptSending,
+ markScheduledPromptSent,
+ normalizeScheduledPrompts,
+ type ScheduledPrompt,
+} from "./scheduledPrompts";
+
+const MAX_TIMER_DELAY_MS = 2_147_483_647;
+
+interface ScheduledPromptDispatcherOptions {
+ api: APIClient | null;
+ workspaceId: string;
+ sendMessageOptions: SendMessageOptions;
+ enabled: boolean;
+ onMessageSendStarted?: (dispatchMode: QueueDispatchMode) => void;
+ onMessageSent?: (dispatchMode: QueueDispatchMode) => void;
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ if (typeof error === "string" && error.trim().length > 0) {
+ return error;
+ }
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "message" in error &&
+ typeof error.message === "string" &&
+ error.message.trim().length > 0
+ ) {
+ return error.message;
+ }
+ return "Failed to send scheduled prompt";
+}
+
+function getCurrentRunnablePrompt(storageKey: string, promptId: string): ScheduledPrompt | null {
+ const currentPrompts = normalizeScheduledPrompts(
+ readPersistedState(storageKey, [])
+ );
+ return getDueScheduledPrompts(currentPrompts).find((prompt) => prompt.id === promptId) ?? null;
+}
+
+export function useScheduledPromptDispatcher(options: ScheduledPromptDispatcherOptions) {
+ const { api, workspaceId, sendMessageOptions, enabled, onMessageSendStarted, onMessageSent } =
+ options;
+ const storageKey = getScheduledPromptsKey(workspaceId);
+ const [storedPrompts, setStoredPrompts] = usePersistedState(storageKey, [], {
+ listener: true,
+ });
+ const prompts = useMemo(() => normalizeScheduledPrompts(storedPrompts), [storedPrompts]);
+ const inFlightIdsRef = useRef(new Set());
+ const isDispatchingRef = useRef(false);
+ const [timerNonce, setTimerNonce] = useState(0);
+
+ useEffect(() => {
+ if (!enabled || !api) {
+ return;
+ }
+
+ const duePrompts = getDueScheduledPrompts(prompts);
+ const nextRunAt = getNextScheduledPromptRunAt(prompts);
+ let delay = 0;
+ if (duePrompts.length === 0) {
+ if (nextRunAt === null) {
+ return;
+ }
+ delay = Math.max(0, Math.min(nextRunAt - Date.now(), MAX_TIMER_DELAY_MS));
+ }
+
+ const timeout = window.setTimeout(() => {
+ if (isDispatchingRef.current) {
+ return;
+ }
+
+ const runnablePrompts = getDueScheduledPrompts(prompts).filter(
+ (prompt) => !inFlightIdsRef.current.has(prompt.id)
+ );
+ if (runnablePrompts.length === 0) {
+ setTimerNonce((current) => current + 1);
+ return;
+ }
+
+ isDispatchingRef.current = true;
+
+ void (async () => {
+ for (const queuedPrompt of runnablePrompts) {
+ if (inFlightIdsRef.current.has(queuedPrompt.id)) {
+ continue;
+ }
+
+ const prompt = getCurrentRunnablePrompt(storageKey, queuedPrompt.id);
+ if (!prompt) {
+ continue;
+ }
+
+ inFlightIdsRef.current.add(prompt.id);
+ setStoredPrompts((current) =>
+ markScheduledPromptSending(normalizeScheduledPrompts(current), prompt.id)
+ );
+
+ const dispatchMode = prompt.queueDispatchMode;
+ const muxMetadata: MuxMessageMetadata = {
+ type: "normal",
+ requestedModel: sendMessageOptions.model,
+ };
+ const prepared = prepareUserMessageForSend({ text: prompt.content }, muxMetadata);
+
+ try {
+ onMessageSendStarted?.(dispatchMode);
+ const result = await api.workspace.sendMessage({
+ workspaceId,
+ message: prepared.finalText,
+ options: {
+ ...sendMessageOptions,
+ queueDispatchMode: dispatchMode,
+ muxMetadata: prepared.metadata,
+ },
+ });
+
+ if (!result?.success) {
+ const error = result?.error ? getErrorMessage(result.error) : "API not connected";
+ setStoredPrompts((current) =>
+ markScheduledPromptFailed(normalizeScheduledPrompts(current), prompt.id, error)
+ );
+ continue;
+ }
+
+ setStoredPrompts((current) =>
+ markScheduledPromptSent(normalizeScheduledPrompts(current), prompt.id)
+ );
+ onMessageSent?.(dispatchMode);
+ } catch (error) {
+ setStoredPrompts((current) =>
+ markScheduledPromptFailed(
+ normalizeScheduledPrompts(current),
+ prompt.id,
+ getErrorMessage(error)
+ )
+ );
+ } finally {
+ inFlightIdsRef.current.delete(prompt.id);
+ }
+ }
+ })().finally(() => {
+ isDispatchingRef.current = false;
+ });
+ }, delay);
+
+ return () => window.clearTimeout(timeout);
+ }, [
+ api,
+ enabled,
+ onMessageSendStarted,
+ onMessageSent,
+ prompts,
+ sendMessageOptions,
+ setStoredPrompts,
+ storageKey,
+ timerNonce,
+ workspaceId,
+ ]);
+}
diff --git a/src/common/constants/storage.test.ts b/src/common/constants/storage.test.ts
index 482057fb2f..a4fe3d2296 100644
--- a/src/common/constants/storage.test.ts
+++ b/src/common/constants/storage.test.ts
@@ -4,6 +4,7 @@ import {
deleteWorkspaceStorage,
getDraftScopeId,
getInputAttachmentsKey,
+ getScheduledPromptsKey,
normalizeTranscriptDensity,
} from "@/common/constants/storage";
@@ -64,36 +65,48 @@ describe("storage workspace-scoped keys", () => {
expect(getInputAttachmentsKey("ws-123")).toBe("inputAttachments:ws-123");
});
+ test("getScheduledPromptsKey formats key", () => {
+ expect(getScheduledPromptsKey("ws-123")).toBe("scheduledPrompts:ws-123");
+ });
+
test("normalizeTranscriptDensity falls back for corrupt values", () => {
expect(normalizeTranscriptDensity("hyper")).toBe("hyper");
expect(normalizeTranscriptDensity("compact")).toBe("normal");
expect(normalizeTranscriptDensity(null)).toBe("normal");
});
- test("copyWorkspaceStorage copies inputAttachments key", () => {
+ test("copyWorkspaceStorage copies draft keys but not scheduled sends", () => {
const source = "ws-source";
const dest = "ws-dest";
- const sourceKey = getInputAttachmentsKey(source);
- const destKey = getInputAttachmentsKey(dest);
+ const sourceAttachmentsKey = getInputAttachmentsKey(source);
+ const destAttachmentsKey = getInputAttachmentsKey(dest);
+ const sourceScheduledPromptsKey = getScheduledPromptsKey(source);
+ const destScheduledPromptsKey = getScheduledPromptsKey(dest);
- const value = JSON.stringify([
+ const attachmentsValue = JSON.stringify([
{ id: "img-1", url: "data:image/png;base64,AAA", mediaType: "image/png" },
]);
- localStorage.setItem(sourceKey, value);
+ const scheduledPromptsValue = JSON.stringify([{ id: "prompt-1" }]);
+ localStorage.setItem(sourceAttachmentsKey, attachmentsValue);
+ localStorage.setItem(sourceScheduledPromptsKey, scheduledPromptsValue);
copyWorkspaceStorage(source, dest);
- expect(localStorage.getItem(destKey)).toBe(value);
+ expect(localStorage.getItem(destAttachmentsKey)).toBe(attachmentsValue);
+ expect(localStorage.getItem(destScheduledPromptsKey)).toBeNull();
});
- test("deleteWorkspaceStorage removes inputAttachments key", () => {
+ test("deleteWorkspaceStorage removes workspace draft and scheduled send keys", () => {
const workspaceId = "ws-delete";
- const key = getInputAttachmentsKey(workspaceId);
+ const attachmentsKey = getInputAttachmentsKey(workspaceId);
+ const scheduledPromptsKey = getScheduledPromptsKey(workspaceId);
- localStorage.setItem(key, "value");
+ localStorage.setItem(attachmentsKey, "value");
+ localStorage.setItem(scheduledPromptsKey, "value");
deleteWorkspaceStorage(workspaceId);
- expect(localStorage.getItem(key)).toBeNull();
+ expect(localStorage.getItem(attachmentsKey)).toBeNull();
+ expect(localStorage.getItem(scheduledPromptsKey)).toBeNull();
});
});
diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts
index c6440a2aaf..735da47118 100644
--- a/src/common/constants/storage.ts
+++ b/src/common/constants/storage.ts
@@ -204,6 +204,14 @@ export function getInputKey(workspaceId: string): string {
return `input:${workspaceId}`;
}
+/**
+ * Get the localStorage key for scheduled prompts for a workspace.
+ * Format: "scheduledPrompts:{workspaceId}"
+ */
+export function getScheduledPromptsKey(workspaceId: string): string {
+ return `scheduledPrompts:${workspaceId}`;
+}
+
/**
* Get the localStorage key for the pinned TODO panel expansion state.
* Format: "pinnedTodoExpanded:{workspaceId}"
@@ -702,6 +710,7 @@ export function getPostCompactionStateKey(workspaceId: string): string {
*/
const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [
getPendingWorkspaceSendErrorKey,
+ getScheduledPromptsKey, // Avoid duplicating future sends when a workspace is forked
getPlanContentKey, // Cache only, no need to preserve on fork
getPostCompactionStateKey, // Cache only, no need to preserve on fork
];