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
77 changes: 77 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { canUseScheduledPromptsInWorkspace } from "@/browser/features/ScheduledPrompts/scheduledPromptAvailability";
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 +142,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 +285,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 = canUseScheduledPromptsInWorkspace(
scheduledPromptWorkspaceMeta
);

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

return (
<>
{scheduledPromptWorkspaceId && scheduledPromptProjectPath ? (
<ActiveWorkspaceScheduledPromptDispatcher
workspaceId={scheduledPromptWorkspaceId}
projectPath={scheduledPromptProjectPath}
enabled={scheduledPromptDispatcherEnabled}
/>
) : null}
<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 @@ -680,6 +681,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 @@ -983,6 +988,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 @@ -225,6 +226,13 @@ export function OutputTabLabel() {
return <>Output</>;
}

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
6 changes: 6 additions & 0 deletions src/browser/features/RightSidebar/Tabs/tabConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading