Skip to content
96 changes: 96 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
usePersistedState,
updatePersistedState,
readPersistedState,
subscribePersistedStateWrites,
} from "./hooks/usePersistedState";
import { useResizableSidebar } from "./hooks/useResizableSidebar";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
Expand Down Expand Up @@ -105,6 +106,18 @@ 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 {
getScheduledPromptDispatcherTargets,
isScheduledPromptsStorageKey,
} from "@/browser/features/ScheduledPrompts/scheduledPromptDispatcherTargets";
import { useScheduledPromptDispatcher } from "@/browser/features/ScheduledPrompts/useScheduledPromptDispatcher";
import {
useAdditionalSystemContextHydrated,
useAdditionalSystemContextSnapshot,
} from "@/browser/utils/additionalSystemContextStore";

function RootRouteShell(props: {
leftSidebarCollapsed: boolean;
Expand Down Expand Up @@ -133,6 +146,56 @@ function RootRouteShell(props: {
);
}

function ActiveWorkspaceScheduledPromptDispatcher(props: {
workspaceId: string;
projectPath: string;
enabled: boolean;
}) {
return (
<AgentProvider
workspaceId={props.workspaceId}
projectPath={props.projectPath}
enableGlobalListeners={false}
>
<ThinkingProvider
workspaceId={props.workspaceId}
projectPath={props.projectPath}
enableGlobalListeners={false}
>
<ActiveWorkspaceScheduledPromptDispatcherInner
workspaceId={props.workspaceId}
enabled={props.enabled}
/>
</ThinkingProvider>
</AgentProvider>
);
}

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 scheduledAdditionalSystemContext = additionalSystemContextHydrated
? additionalSystemContext.enabled
? additionalSystemContext.content
: ""
: undefined;

useScheduledPromptDispatcher({
api,
workspaceId: props.workspaceId,
sendMessageOptions,
additionalSystemContext: scheduledAdditionalSystemContext,
enabled: props.enabled,
});

return null;
}

function AppInner() {
// Get workspace state from context
const {
Expand Down Expand Up @@ -226,6 +289,31 @@ function AppInner() {
}, [sidebarCollapsed]);
const creationProjectPath =
!selectedWorkspace && !currentWorkspaceId ? pendingNewWorkspaceProject : null;
const [, bumpScheduledPromptStorageVersion] = useState(0);
useEffect(() => {
const bumpVersion = () => {
bumpScheduledPromptStorageVersion((version) => version + 1);
};
const unsubscribe = subscribePersistedStateWrites((event) => {
if (isScheduledPromptsStorageKey(event.key)) {
bumpVersion();
}
});
const handleStorage = (event: StorageEvent) => {
if (event.key && isScheduledPromptsStorageKey(event.key)) {
bumpVersion();
}
};
window.addEventListener("storage", handleStorage);
return () => {
unsubscribe();
window.removeEventListener("storage", handleStorage);
};
}, []);
const scheduledPromptDispatcherTargets = getScheduledPromptDispatcherTargets(
workspaceMetadata,
(storageKey) => readPersistedState(storageKey, [])
);

// History navigation (back/forward)
const navigate = useNavigate();
Expand Down Expand Up @@ -1071,6 +1159,14 @@ function AppInner() {

return (
<>
{scheduledPromptDispatcherTargets.map((target) => (
<ActiveWorkspaceScheduledPromptDispatcher
key={target.workspaceId}
workspaceId={target.workspaceId}
projectPath={target.projectPath}
enabled={true}
/>
))}
<div className="bg-surface-primary mobile-layout flex h-full overflow-hidden pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[min(env(safe-area-inset-bottom,0px),40px)] pl-[env(safe-area-inset-left)]">
<LeftSidebar
collapsed={sidebarCollapsed}
Expand Down
57 changes: 56 additions & 1 deletion src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,19 @@ function createApiClient(): APIClient {
function renderAgentHarness(props: {
projectPath: string;
workspaceId?: string;
enableGlobalListeners?: boolean;
onChange: (value: AgentContextValue) => void;
}) {
return render(
<APIProvider client={createApiClient()}>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<AgentProvider workspaceId={props.workspaceId} projectPath={props.projectPath}>
<AgentProvider
workspaceId={props.workspaceId}
projectPath={props.projectPath}
enableGlobalListeners={props.enableGlobalListeners}
>
<Harness onChange={props.onChange} />
</AgentProvider>
</WorkspaceProvider>
Expand Down Expand Up @@ -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];
Expand Down
14 changes: 12 additions & 2 deletions src/browser/contexts/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type AgentProviderProps =
| {
workspaceId?: string;
projectPath?: string;
enableGlobalListeners?: boolean;
children: ReactNode;
};

Expand All @@ -78,6 +79,7 @@ export function AgentProvider(props: AgentProviderProps) {
function AgentProviderWithState(props: {
workspaceId?: string;
projectPath?: string;
enableGlobalListeners?: boolean;
children: ReactNode;
}) {
const { api } = useAPI();
Expand Down Expand Up @@ -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();
Expand All @@ -315,17 +321,21 @@ 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();
};

window.addEventListener(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED, handleRefreshRequested);
return () =>
window.removeEventListener(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED, handleRefreshRequested);
}, [refresh]);
}, [props.enableGlobalListeners, refresh]);

const agentContextValue = useMemo(
() => ({
Expand Down
31 changes: 31 additions & 0 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,4 +567,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 <div data-testid="thinking-project">{thinkingLevel}</div>;
};

const view = renderWithAPI(
<ThinkingProvider projectPath={projectPath} enableGlobalListeners={false}>
<ProjectChild />
</ThinkingProvider>
);

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");
});
});
15 changes: 14 additions & 1 deletion src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const ThinkingContext = createContext<ThinkingContextType | undefined>(undefined
interface ThinkingProviderProps {
workspaceId?: string; // Workspace-scoped storage (highest priority)
projectPath?: string; // Project-scoped storage (fallback if no workspaceId)
enableGlobalListeners?: boolean;
children: ReactNode;
}

Expand Down Expand Up @@ -176,6 +177,10 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
// Implemented at the ThinkingProvider level so they work 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) => {
const isIncrease = matchesKeybind(e, KEYBINDS.INCREASE_THINKING);
const isDecrease = matchesKeybind(e, KEYBINDS.DECREASE_THINKING);
Expand Down Expand Up @@ -213,7 +218,15 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (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(
Expand Down
22 changes: 22 additions & 0 deletions src/browser/features/RightSidebar/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
type TerminalSessionCreateOptions,
} from "@/browser/utils/terminal";
import { ReviewAssistedStatsReporter } from "@/browser/features/RightSidebar/CodeReview/ReviewPanel";
import { canUseScheduledPromptsInWorkspace } from "@/browser/features/ScheduledPrompts/scheduledPromptAvailability";
import {
TAB_REGISTRY,
TerminalTabLabel,
Expand Down Expand Up @@ -682,6 +683,10 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
const currentWorkspaceMetadata =
workspaceMetadataContext.workspaceMetadata.get(workspaceId) ?? null;
const isChildWorkspaceForGoal = currentWorkspaceMetadata?.parentWorkspaceId != null;
const scheduleTabAvailable =
currentWorkspaceMetadata === null
? null
: canUseScheduledPromptsInWorkspace(currentWorkspaceMetadata);
// Safe variant: storybook stories may render before addWorkspace() runs; the
// optional hook returns null instead of throwing assertGet on the unregistered
// workspace. Real workspaces always have an aggregator by the time RightSidebar
Expand Down Expand Up @@ -1023,6 +1028,23 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
});
}, [initialActiveTab, setLayoutRaw, isChildWorkspaceForGoal]);

React.useEffect(() => {
if (scheduleTabAvailable === null) {
return;
}

setLayoutRaw((prevRaw) => {
const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab);
const hasSchedule = collectAllTabs(prev.root).includes("schedule");

if (!scheduleTabAvailable && hasSchedule) {
return removeTabEverywhere(prev, "schedule");
}

return prev;
});
}, [initialActiveTab, scheduleTabAvailable, setLayoutRaw]);

React.useEffect(() => {
if (!desktopExperimentEnabled) {
setDesktopAvailable(false);
Expand Down
8 changes: 8 additions & 0 deletions src/browser/features/RightSidebar/Tabs/TabLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import React from "react";
import {
BugPlay,
Clock3,
ExternalLink,
Monitor,
Globe,
Expand Down Expand Up @@ -259,6 +260,13 @@ export function MemoryTabLabel() {
return <>Memory</>;
}

export const ScheduleTabLabel: React.FC = () => (
<span className="inline-flex items-center gap-1">
<Clock3 className="h-3 w-3 shrink-0" />
Schedule
</span>
);

interface InstructionsTabLabelProps {
workspaceId: string;
}
Expand Down
Loading