Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
119 changes: 119 additions & 0 deletions src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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,6 +114,9 @@ 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);
Expand Down Expand Up @@ -134,6 +160,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 +383,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 +444,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 +476,29 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
return () => window.removeEventListener("keydown", handler);
}, [setNotifyOnResponse]);

useEffect(() => {
if (!githubReviewNotificationsExperimentEnabled || !hasRepository) {
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,
workspaceEntry?.githubReviewNotificationsEnabled,
]);

useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (matchesKeybind(e, KEYBINDS.SHOW_WORKSPACE_DETAILS)) {
Expand Down Expand Up @@ -601,6 +691,18 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</span>
</span>
</label>
{githubReviewNotificationsExperimentEnabled && hasRepository && (
<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 +745,18 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</span>
</span>
</label>
{githubReviewNotificationsExperimentEnabled && hasRepository && (
<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 +956,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
2 changes: 2 additions & 0 deletions src/constants/githubReviewNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Prefix for queued GitHub review notification dedupe keys. */
export const GITHUB_REVIEW_NOTIFICATION_QUEUE_DEDUPE_PREFIX = "github-pr-review:";
11 changes: 10 additions & 1 deletion src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,7 @@ export class Config {
runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG,
aiSettings: workspace.aiSettings,
heartbeat: normalizeWorkspaceMetadataHeartbeat(workspace.heartbeat, config),
githubReviewNotificationsEnabled: workspace.githubReviewNotificationsEnabled,
goalDefaults: workspace.goalDefaults,
aiSettingsByAgent:
workspace.aiSettingsByAgent ??
Expand Down Expand Up @@ -2356,6 +2357,7 @@ export class Config {
runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG,
aiSettings: workspace.aiSettings,
heartbeat: workspace.heartbeat,
githubReviewNotificationsEnabled: workspace.githubReviewNotificationsEnabled,
goalDefaults: workspace.goalDefaults,
aiSettingsByAgent:
workspace.aiSettingsByAgent ??
Expand Down Expand Up @@ -2424,6 +2426,7 @@ export class Config {
runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG,
aiSettings: workspace.aiSettings,
heartbeat: workspace.heartbeat,
githubReviewNotificationsEnabled: workspace.githubReviewNotificationsEnabled,
goalDefaults: workspace.goalDefaults,
aiSettingsByAgent:
workspace.aiSettingsByAgent ??
Expand Down Expand Up @@ -2529,6 +2532,7 @@ export class Config {
runtimeConfig: metadata.runtimeConfig,
aiSettings: metadata.aiSettings,
heartbeat: metadata.heartbeat,
githubReviewNotificationsEnabled: metadata.githubReviewNotificationsEnabled,
goalDefaults: metadata.goalDefaults,
parentWorkspaceId: metadata.parentWorkspaceId,
agentType: metadata.agentType,
Expand Down Expand Up @@ -2599,14 +2603,19 @@ export class Config {
*/
async updateWorkspaceMetadata(
workspaceId: string,
updates: Partial<Pick<WorkspaceMetadata, "name" | "runtimeConfig">>
updates: Partial<
Pick<WorkspaceMetadata, "name" | "runtimeConfig" | "githubReviewNotificationsEnabled">
>
): Promise<void> {
await this.editConfig((config) => {
for (const [_projectPath, projectConfig] of config.projects) {
const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId);
if (workspace) {
if (updates.name !== undefined) workspace.name = updates.name;
if (updates.runtimeConfig !== undefined) workspace.runtimeConfig = updates.runtimeConfig;
if (updates.githubReviewNotificationsEnabled !== undefined) {
workspace.githubReviewNotificationsEnabled = updates.githubReviewNotificationsEnabled;
}
return config;
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4242,6 +4242,17 @@ export const router = (authToken?: string) => {
return Ok(undefined);
}),
},
githubReviewNotifications: {
set: t
.input(schemas.workspace.githubReviewNotifications.set.input)
.output(schemas.workspace.githubReviewNotifications.set.output)
.handler(({ context, input }) =>
context.workspaceService.setGitHubReviewNotificationsEnabled(
input.workspaceId,
input.enabled
)
),
},
goalDefaults: {
// Per-workspace override of the global `goalDefaults` block.
// `get` returns `null` when this workspace has no override.
Expand Down
Loading
Loading