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
140 changes: 139 additions & 1 deletion src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { formatKeybind, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keyb
import { useRuntimeStatus, useRuntimeStatusStoreRaw } from "@/browser/stores/RuntimeStatusStore";
import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
import { Button } from "@/browser/components/Button/Button";
import { isDevcontainerRuntime, type RuntimeConfig } from "@/common/types/runtime";
import {
isDevcontainerRuntime,
supportsGitHubReviewNotifications,
type RuntimeConfig,
} from "@/common/types/runtime";
import { useTutorial } from "@/browser/contexts/TutorialContext";

import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal";
Expand All @@ -47,6 +51,7 @@ import { formatProjectHierarchyLabel } from "@/common/utils/subProjects";
import { forkWorkspace } from "@/browser/utils/chatCommands";
import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch";
import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities";
import { isMultiProject } from "@/common/utils/multiProject";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import { WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX } from "@/constants/layout";
import type { AgentSkillDescriptor, AgentSkillIssue } from "@/common/types/agentSkill";
Expand Down Expand Up @@ -74,6 +79,29 @@ const COLLAPSED_LEFT_SIDEBAR_MENU_BAR_STYLE = {
paddingLeft: `${WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX}px`,
} as const;

function GitHubReviewNotificationsOption(props: {
checked: boolean;
disabled: boolean;
shortcutLabel?: string;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start gap-2">
<Checkbox
checked={props.checked}
disabled={props.disabled}
onCheckedChange={(checked) => props.onCheckedChange(checked === true)}
/>
<span className="text-muted-foreground">
Notify when a GitHub PR review is posted
{props.shortcutLabel != null && (
<span className="text-muted-foreground"> ({props.shortcutLabel})</span>
)}
</span>
</label>
);
}

export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
workspaceId,
projectName,
Expand All @@ -91,11 +119,19 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
const { preflightArchiveWorkspace, archiveWorkspace, setWorkspacePinned } = useWorkspaceActions();
const { workspaceMetadata } = useWorkspaceContext();
const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS);
const githubReviewNotificationsExperimentEnabled = useExperimentValue(
EXPERIMENT_IDS.GITHUB_PR_REVIEW_NOTIFICATIONS
);
const openTerminalPopout = useOpenTerminal();
const openInEditor = useOpenInEditor();
const runtimeStatus = useRuntimeStatus(workspaceId);
const workspaceEntry = workspaceMetadata.get(workspaceId);
const hasRepository = hasWorkspaceRepository(workspaceEntry);
// Do not offer a setting that cannot poll without starting remote infrastructure.
const githubReviewNotificationsSupported = supportsGitHubReviewNotifications(
runtimeConfig,
workspaceEntry != null && isMultiProject(workspaceEntry)
);
// The workspace's metadata.projectName is the parent project (since worktrees
// are owned by the top-most parent). When the workspace is scoped to a
// sub-project we surface the hierarchy as "parent / child" so the menu bar
Expand Down Expand Up @@ -134,6 +170,9 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
const archiveError = usePopoverError();
const forkError = usePopoverError();
const stopRuntimeError = usePopoverError();
const githubReviewNotificationsError = usePopoverError();
const [githubReviewNotificationsUpdatePending, setGithubReviewNotificationsUpdatePending] =
useState(false);

const [rightSidebarCollapsed] = usePersistedState<boolean>(RIGHT_SIDEBAR_COLLAPSED_KEY, false, {
// This state is toggled from RightSidebar, so we need cross-component updates.
Expand Down Expand Up @@ -354,6 +393,40 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
}
}, [api, getMoreMenuAnchor, runtimeStatusStore, stopRuntimeError, workspaceId]);

const handleGitHubReviewNotificationsChange = (enabled: boolean): void => {
if (!api) {
githubReviewNotificationsError.showError(
workspaceId,
"Not connected to server",
getMoreMenuAnchor()
);
return;
}

setGithubReviewNotificationsUpdatePending(true);
api.workspace.githubReviewNotifications
.set({ workspaceId, enabled })
.then((result) => {
if (!result.success) {
githubReviewNotificationsError.showError(
workspaceId,
result.error ?? "Failed to update GitHub review notifications",
getMoreMenuAnchor()
);
}
})
.catch((error: unknown) => {
githubReviewNotificationsError.showError(
workspaceId,
getErrorMessage(error),
getMoreMenuAnchor()
);
})
.finally(() => {
setGithubReviewNotificationsUpdatePending(false);
});
};

const loadSkills = useCallback(async () => {
const requestId = ++skillsRequestIdRef.current;

Expand Down Expand Up @@ -381,6 +454,10 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
}
}, [api, workspaceId, disableWorkspaceAgents]);

const githubReviewNotificationsChangeRef = useRef<(enabled: boolean) => void>(() => undefined);
// Keep the global shortcut listener stable while it reads the latest API and workspace state.
githubReviewNotificationsChangeRef.current = handleGitHubReviewNotificationsChange;

// Start workspace tutorial on first entry
useEffect(() => {
// Small delay to ensure UI is rendered
Expand Down Expand Up @@ -409,6 +486,34 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
return () => window.removeEventListener("keydown", handler);
}, [setNotifyOnResponse]);

useEffect(() => {
if (
!githubReviewNotificationsExperimentEnabled ||
!hasRepository ||
!githubReviewNotificationsSupported
) {
return;
}

const handler = (e: KeyboardEvent) => {
if (!matchesKeybind(e, KEYBINDS.TOGGLE_GITHUB_REVIEW_NOTIFICATIONS)) {
return;
}

e.preventDefault();
githubReviewNotificationsChangeRef.current(
workspaceEntry?.githubReviewNotificationsEnabled !== true
);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
githubReviewNotificationsExperimentEnabled,
hasRepository,
githubReviewNotificationsSupported,
workspaceEntry?.githubReviewNotificationsEnabled,
]);

useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (matchesKeybind(e, KEYBINDS.SHOW_WORKSPACE_DETAILS)) {
Expand Down Expand Up @@ -601,6 +706,20 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</span>
</span>
</label>
{githubReviewNotificationsExperimentEnabled &&
hasRepository &&
githubReviewNotificationsSupported && (
<GitHubReviewNotificationsOption
checked={workspaceEntry?.githubReviewNotificationsEnabled === true}
disabled={githubReviewNotificationsUpdatePending}
shortcutLabel={
isTouchMobileScreen
? undefined
: formatKeybind(KEYBINDS.TOGGLE_GITHUB_REVIEW_NOTIFICATIONS)
}
onCheckedChange={handleGitHubReviewNotificationsChange}
/>
)}
<label className="flex cursor-pointer items-start gap-2">
<Checkbox
checked={autoEnableNotifications}
Expand Down Expand Up @@ -643,6 +762,20 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</span>
</span>
</label>
{githubReviewNotificationsExperimentEnabled &&
hasRepository &&
githubReviewNotificationsSupported && (
<GitHubReviewNotificationsOption
checked={workspaceEntry?.githubReviewNotificationsEnabled === true}
disabled={githubReviewNotificationsUpdatePending}
shortcutLabel={
isTouchMobileScreen
? undefined
: formatKeybind(KEYBINDS.TOGGLE_GITHUB_REVIEW_NOTIFICATIONS)
}
onCheckedChange={handleGitHubReviewNotificationsChange}
/>
)}
<label className="flex cursor-pointer items-start gap-2">
<Checkbox
checked={autoEnableNotifications}
Expand Down Expand Up @@ -842,6 +975,11 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
prefix="Failed to fork chat"
onDismiss={forkError.clearError}
/>
<PopoverError
error={githubReviewNotificationsError.error}
prefix="Failed to update GitHub review notifications"
onDismiss={githubReviewNotificationsError.clearError}
/>
<PopoverError
error={archiveError.error}
prefix="Failed to archive chat"
Expand Down
2 changes: 2 additions & 0 deletions src/browser/features/Settings/Sections/KeybindsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const KEYBIND_LABELS: Record<keyof typeof KEYBINDS, string> = {
NAVIGATE_BACK: "Navigate back",
NAVIGATE_FORWARD: "Navigate forward",
TOGGLE_NOTIFICATIONS: "Toggle notifications",
TOGGLE_GITHUB_REVIEW_NOTIFICATIONS: "Toggle GitHub review notifications",
TOGGLE_DRIFT_MODE: "Toggle git drift lines/commits",
SHOW_WORKSPACE_DETAILS: "Show workspace details",
SHOW_LAST_PROMPT: "Show last prompt",
Expand Down Expand Up @@ -128,6 +129,7 @@ const KEYBIND_GROUPS: Array<{
"INCREASE_THINKING",
"TOGGLE_FAST_MODE",
"TOGGLE_NOTIFICATIONS",
"TOGGLE_GITHUB_REVIEW_NOTIFICATIONS",
"TOGGLE_DRIFT_MODE",
"SHOW_WORKSPACE_DETAILS",
"CONFIGURE_MCP",
Expand Down
4 changes: 4 additions & 0 deletions src/browser/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,10 @@ export const KEYBINDS = {
// "N" for Notifications
TOGGLE_NOTIFICATIONS: { key: "N", ctrl: true, shift: true },

/** Toggle GitHub pull request review notifications for current workspace */
// macOS: Cmd+Option+G, Win/Linux: Ctrl+Alt+G
TOGGLE_GITHUB_REVIEW_NOTIFICATIONS: { key: "G", code: "KeyG", ctrl: true, alt: true },

TOGGLE_DRIFT_MODE: { key: "G", ctrl: true, shift: true },

SHOW_WORKSPACE_DETAILS: { key: "D", ctrl: true, shift: true },
Expand Down
9 changes: 9 additions & 0 deletions src/common/constants/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const EXPERIMENT_IDS = {
AGENT_PLUGINS: "agent-plugins",
SKILL_DYNAMIC_CONTEXT: "skill-dynamic-context",
TIMELINE: "timeline",
GITHUB_PR_REVIEW_NOTIFICATIONS: "github-pr-review-notifications",
} as const;

export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS];
Expand Down Expand Up @@ -194,6 +195,14 @@ export const EXPERIMENTS: Record<ExperimentId, ExperimentDefinition> = {
enabledByDefault: false,
showInSettings: true,
},
[EXPERIMENT_IDS.GITHUB_PR_REVIEW_NOTIFICATIONS]: {
id: EXPERIMENT_IDS.GITHUB_PR_REVIEW_NOTIFICATIONS,
name: "GitHub PR review notifications",
description:
"Notify enabled workspaces when a new review is posted on their GitHub pull request",
enabledByDefault: false,
showInSettings: true,
},
};

function getPlatformDisplayName(platform: NodeJS.Platform): string {
Expand Down
9 changes: 9 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,15 @@ export const workspace = {
output: ResultSchema(z.void(), z.string()),
},
},
githubReviewNotifications: {
set: {
input: z.object({
workspaceId: z.string(),
enabled: z.boolean(),
}),
output: ResultSchema(z.void(), z.string()),
},
},
goalDefaults: {
// Per-workspace override of the global `goalDefaults` block. `get`
// returns `null` when no override is set (i.e., this workspace uses
Expand Down
4 changes: 4 additions & 0 deletions src/common/orpc/schemas/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ export const WorkspaceMetadataSchema = z.object({
heartbeat: WorkspaceHeartbeatSettingsSchema.optional().meta({
description: "Persisted heartbeat settings for this workspace.",
}),
githubReviewNotificationsEnabled: z.boolean().optional().meta({
description:
"Whether this workspace receives notifications for new GitHub pull request reviews.",
}),
goalDefaults: WorkspaceGoalDefaultsOverrideSchema.optional().meta({
description:
"Per-workspace overrides for goal creation defaults (budget, turn cap, explicit-budget). Layered on top of the global `goalDefaults` from app config.",
Expand Down
4 changes: 4 additions & 0 deletions src/common/schemas/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export const WorkspaceConfigSchema = z.object({
heartbeat: WorkspaceHeartbeatSettingsSchema.optional().meta({
description: "Persisted heartbeat settings for this workspace.",
}),
githubReviewNotificationsEnabled: z.boolean().optional().meta({
description:
"Whether this workspace receives notifications for new GitHub pull request reviews.",
}),
goalDefaults: WorkspaceGoalDefaultsOverrideSchema.optional().meta({
description:
"Per-workspace overrides for goal creation defaults. Sparse; each null field follows the global `goalDefaults`.",
Expand Down
6 changes: 6 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,12 @@ export type MuxMessageMetadata = MuxMessageMetadataBase &
*/
firedAt?: number;
}
| {
/** Synthetic provider-visible message for reviews posted on the linked GitHub PR. */
type: "github-pr-review-notification";
prUrl: string;
reviewIds: string[];
}
| {
type: "normal"; // Regular messages
/** Original user input for one-shot overrides (e.g., "/opus+high do something") — used as display content so the command prefix remains visible. */
Expand Down
35 changes: 35 additions & 0 deletions src/common/types/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseRuntimeModeAndHost,
RUNTIME_MODE,
runtimeModeSupportsSharedTaskWorkspace,
supportsGitHubReviewNotifications,
} from "./runtime";

describe("runtimeModeSupportsSharedTaskWorkspace", () => {
Expand All @@ -21,6 +22,40 @@ describe("runtimeModeSupportsSharedTaskWorkspace", () => {
});
});

describe("supportsGitHubReviewNotifications", () => {
it("supports local, worktree, and single-project devcontainer runtimes", () => {
expect(supportsGitHubReviewNotifications({ type: "local" })).toBe(true);
expect(supportsGitHubReviewNotifications({ type: "worktree", srcBaseDir: "/tmp/src" })).toBe(
true
);
expect(
supportsGitHubReviewNotifications({
type: "devcontainer",
configPath: ".devcontainer/devcontainer.json",
})
).toBe(true);
});

it("does not support remote, Docker, or multi-project devcontainer polling", () => {
expect(
supportsGitHubReviewNotifications({
type: "ssh",
host: "example.com",
srcBaseDir: "/tmp/src",
})
).toBe(false);
expect(supportsGitHubReviewNotifications({ type: "docker", image: "ubuntu:24.04" })).toBe(
false
);
expect(
supportsGitHubReviewNotifications(
{ type: "devcontainer", configPath: ".devcontainer/devcontainer.json" },
true
)
).toBe(false);
});
});

describe("parseRuntimeModeAndHost", () => {
it("parses SSH mode with host", () => {
expect(parseRuntimeModeAndHost("ssh user@host")).toEqual({
Expand Down
19 changes: 19 additions & 0 deletions src/common/types/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,25 @@ export function isLocalProjectRuntime(
return config.type === "local" && !("srcBaseDir" in config && config.srcBaseDir);
}

/**
* Return whether review notifications can poll this runtime without starting infrastructure.
* Multi-project devcontainers need a status probe for every project container.
*/
export function supportsGitHubReviewNotifications(
config: RuntimeConfig | undefined,
isMultiProjectWorkspace = false
): boolean {
if (config == null) {
return !isMultiProjectWorkspace;
}
if (isMultiProjectWorkspace && isDevcontainerRuntime(config)) {
return false;
}
return (
isWorktreeRuntime(config) || isLocalProjectRuntime(config) || isDevcontainerRuntime(config)
);
}

/**
* Type guard to check if a runtime config has srcBaseDir (worktree-style runtimes).
* This narrows the type to allow safe access to srcBaseDir.
Expand Down
Loading
Loading