From 7a1bb83ccc89ddf65ebba618d4c304d6ee013382 Mon Sep 17 00:00:00 2001 From: Xiayu Zhai Date: Fri, 29 May 2026 22:05:56 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20scheduled=20prompt?= =?UTF-8?q?=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a right sidebar Schedule tab for workspace-scoped scheduled prompts, including local lifecycle state, run-now/delete controls, and an active-workspace dispatcher that submits due prompts with the current send options.\n\nLinks #3417.\n\n---\n\n_Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_\n\n --- src/browser/App.tsx | 98 +++++++- src/browser/contexts/AgentContext.test.tsx | 57 ++++- src/browser/contexts/AgentContext.tsx | 14 +- src/browser/contexts/ThinkingContext.test.tsx | 31 +++ src/browser/contexts/ThinkingContext.tsx | 15 +- .../features/RightSidebar/Tabs/TabLabels.tsx | 8 + .../features/RightSidebar/Tabs/tabConfig.ts | 6 + .../RightSidebar/Tabs/tabRegistry.tsx | 10 + .../ScheduledPrompts/ScheduledPromptsTab.tsx | 211 ++++++++++++++++++ .../ScheduledPrompts/scheduledPrompts.test.ts | 119 ++++++++++ .../ScheduledPrompts/scheduledPrompts.ts | 193 ++++++++++++++++ .../useScheduledPromptDispatcher.test.tsx | 173 ++++++++++++++ .../useScheduledPromptDispatcher.ts | 174 +++++++++++++++ src/common/constants/storage.test.ts | 33 ++- src/common/constants/storage.ts | 9 + 15 files changed, 1136 insertions(+), 15 deletions(-) create mode 100644 src/browser/features/ScheduledPrompts/ScheduledPromptsTab.tsx create mode 100644 src/browser/features/ScheduledPrompts/scheduledPrompts.test.ts create mode 100644 src/browser/features/ScheduledPrompts/scheduledPrompts.ts create mode 100644 src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.test.tsx create mode 100644 src/browser/features/ScheduledPrompts/useScheduledPromptDispatcher.ts 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 ( +
+
+
+
+
+