Skip to content

Commit a2d559b

Browse files
Agent Relayclaude
andcommitted
feat: Add workspace settings UI for automated PR reviews
Add Automations section to workspace settings panel with PR review configuration form. Integrates with relay-cloud PR #79's PATCH /api/workspaces/:id/config endpoint. Features: - Enable/disable toggle for automated PR reviews - Reviewer selection (Claude, Codex, Peer Review) - Exclude labels input with tag-style UI - Exclude authors input with tag-style UI - Max files changed slider with range input (10-200) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 414370c commit a2d559b

2 files changed

Lines changed: 398 additions & 1 deletion

File tree

packages/dashboard/src/components/settings/WorkspaceSettingsPanel.tsx

Lines changed: 352 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function WorkspaceSettingsPanel({
144144
const [availableRepos, setAvailableRepos] = useState<AvailableRepo[]>([]);
145145
const [isLoading, setIsLoading] = useState(true);
146146
const [error, setError] = useState<string | null>(null);
147-
const [activeSection, setActiveSection] = useState<'general' | 'providers' | 'repos' | 'github-access' | 'domain' | 'danger'>('general');
147+
const [activeSection, setActiveSection] = useState<'general' | 'providers' | 'repos' | 'github-access' | 'automations' | 'domain' | 'danger'>('general');
148148

149149
// Provider connection state
150150
const [providerStatus, setProviderStatus] = useState<Record<string, boolean>>({});
@@ -178,6 +178,26 @@ export function WorkspaceSettingsPanel({
178178
ttl: number;
179179
} | null>(null);
180180

181+
// PR Review config state
182+
const [prReviewConfig, setPrReviewConfig] = useState<{
183+
enabled: boolean;
184+
reviewers: string[];
185+
excludeLabels: string[];
186+
excludeAuthors: string[];
187+
maxFilesChanged: number;
188+
}>({
189+
enabled: false,
190+
reviewers: ['claude'],
191+
excludeLabels: ['wip', 'do-not-review'],
192+
excludeAuthors: ['dependabot[bot]'],
193+
maxFilesChanged: 50,
194+
});
195+
const [prReviewLoading, setPrReviewLoading] = useState(false);
196+
const [prReviewError, setPrReviewError] = useState<string | null>(null);
197+
const [prReviewSuccess, setPrReviewSuccess] = useState(false);
198+
const [excludeLabelInput, setExcludeLabelInput] = useState('');
199+
const [excludeAuthorInput, setExcludeAuthorInput] = useState('');
200+
181201
// Load workspace details
182202
useEffect(() => {
183203
// Skip loading if workspaceId is invalid (not a UUID)
@@ -224,12 +244,90 @@ export function WorkspaceSettingsPanel({
224244
setProviderStatus(connected);
225245
}
226246

247+
// Load PR review config
248+
const configResult = await cloudApi.getWorkspaceConfig(workspaceId);
249+
if (configResult.success && configResult.data.prReview) {
250+
setPrReviewConfig(configResult.data.prReview);
251+
}
252+
227253
setIsLoading(false);
228254
}
229255

230256
loadWorkspace();
231257
}, [workspaceId]);
232258

259+
// Save PR review config
260+
const handleSavePrReviewConfig = useCallback(async () => {
261+
if (!workspace) return;
262+
263+
setPrReviewLoading(true);
264+
setPrReviewError(null);
265+
setPrReviewSuccess(false);
266+
267+
const result = await cloudApi.updateWorkspaceConfig(workspace.id, {
268+
prReview: prReviewConfig,
269+
});
270+
271+
if (result.success) {
272+
setPrReviewSuccess(true);
273+
setTimeout(() => setPrReviewSuccess(false), 3000);
274+
} else {
275+
setPrReviewError(result.error);
276+
}
277+
278+
setPrReviewLoading(false);
279+
}, [workspace, prReviewConfig]);
280+
281+
// Toggle reviewer selection
282+
const toggleReviewer = useCallback((reviewer: string) => {
283+
setPrReviewConfig((prev) => ({
284+
...prev,
285+
reviewers: prev.reviewers.includes(reviewer)
286+
? prev.reviewers.filter((r) => r !== reviewer)
287+
: [...prev.reviewers, reviewer],
288+
}));
289+
}, []);
290+
291+
// Add exclude label
292+
const addExcludeLabel = useCallback(() => {
293+
const label = excludeLabelInput.trim();
294+
if (label && !prReviewConfig.excludeLabels.includes(label)) {
295+
setPrReviewConfig((prev) => ({
296+
...prev,
297+
excludeLabels: [...prev.excludeLabels, label],
298+
}));
299+
setExcludeLabelInput('');
300+
}
301+
}, [excludeLabelInput, prReviewConfig.excludeLabels]);
302+
303+
// Remove exclude label
304+
const removeExcludeLabel = useCallback((label: string) => {
305+
setPrReviewConfig((prev) => ({
306+
...prev,
307+
excludeLabels: prev.excludeLabels.filter((l) => l !== label),
308+
}));
309+
}, []);
310+
311+
// Add exclude author
312+
const addExcludeAuthor = useCallback(() => {
313+
const author = excludeAuthorInput.trim();
314+
if (author && !prReviewConfig.excludeAuthors.includes(author)) {
315+
setPrReviewConfig((prev) => ({
316+
...prev,
317+
excludeAuthors: [...prev.excludeAuthors, author],
318+
}));
319+
setExcludeAuthorInput('');
320+
}
321+
}, [excludeAuthorInput, prReviewConfig.excludeAuthors]);
322+
323+
// Remove exclude author
324+
const removeExcludeAuthor = useCallback((author: string) => {
325+
setPrReviewConfig((prev) => ({
326+
...prev,
327+
excludeAuthors: prev.excludeAuthors.filter((a) => a !== author),
328+
}));
329+
}, []);
330+
233331
// Start CLI-based OAuth flow for a provider
234332
// This just sets state to show the ProviderAuthFlow component, which handles the actual auth
235333
const startOAuthFlow = (provider: AIProvider) => {
@@ -538,6 +636,7 @@ export function WorkspaceSettingsPanel({
538636
{ id: 'general', label: 'General', icon: <SettingsGearIcon /> },
539637
{ id: 'providers', label: 'AI Providers', icon: <ProviderIcon /> },
540638
{ id: 'repos', label: 'Repositories', icon: <RepoIcon /> },
639+
{ id: 'automations', label: 'Automations', icon: <AutomationIcon /> },
541640
{ id: 'domain', label: 'Domain', icon: <GlobeIcon /> },
542641
{ id: 'danger', label: 'Danger', icon: <AlertIcon /> },
543642
];
@@ -966,6 +1065,209 @@ export function WorkspaceSettingsPanel({
9661065
</div>
9671066
)}
9681067

1068+
{/* Automations Section */}
1069+
{activeSection === 'automations' && (
1070+
<div className="space-y-8">
1071+
<SectionHeader
1072+
title="Automations"
1073+
subtitle="Configure automated workflows for your workspace"
1074+
/>
1075+
1076+
{/* PR Review Automation */}
1077+
<div className="p-5 bg-bg-tertiary rounded-xl border border-border-subtle">
1078+
<div className="flex items-center justify-between mb-6">
1079+
<div className="flex items-center gap-4">
1080+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-purple to-accent-cyan flex items-center justify-center">
1081+
<PullRequestIcon />
1082+
</div>
1083+
<div>
1084+
<h4 className="text-base font-semibold text-text-primary">Automated PR Reviews</h4>
1085+
<p className="text-sm text-text-muted">AI-powered code review for pull requests</p>
1086+
</div>
1087+
</div>
1088+
<Toggle
1089+
checked={prReviewConfig.enabled}
1090+
onChange={(v) => setPrReviewConfig((prev) => ({ ...prev, enabled: v }))}
1091+
/>
1092+
</div>
1093+
1094+
{prReviewConfig.enabled && (
1095+
<div className="space-y-6 pt-4 border-t border-border-subtle">
1096+
{/* Reviewers Selection */}
1097+
<div>
1098+
<label className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3 block">
1099+
Reviewers
1100+
</label>
1101+
<p className="text-xs text-text-muted mb-3">Select which AI agents will review PRs</p>
1102+
<div className="flex flex-wrap gap-3">
1103+
{[
1104+
{ id: 'claude', label: 'Claude', color: '#D97757' },
1105+
{ id: 'codex', label: 'Codex', color: '#10A37F' },
1106+
{ id: 'peer', label: 'Peer Review', color: '#7C3AED' },
1107+
].map((reviewer) => (
1108+
<button
1109+
key={reviewer.id}
1110+
onClick={() => toggleReviewer(reviewer.id)}
1111+
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg border transition-all ${
1112+
prReviewConfig.reviewers.includes(reviewer.id)
1113+
? 'bg-accent-cyan/10 border-accent-cyan/30 text-accent-cyan'
1114+
: 'bg-bg-card border-border-subtle text-text-secondary hover:border-border-medium'
1115+
}`}
1116+
>
1117+
<div
1118+
className="w-3 h-3 rounded-full"
1119+
style={{ backgroundColor: reviewer.color }}
1120+
/>
1121+
{reviewer.label}
1122+
{prReviewConfig.reviewers.includes(reviewer.id) && (
1123+
<CheckIcon />
1124+
)}
1125+
</button>
1126+
))}
1127+
</div>
1128+
</div>
1129+
1130+
{/* Exclude Labels */}
1131+
<div>
1132+
<label className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3 block">
1133+
Exclude Labels
1134+
</label>
1135+
<p className="text-xs text-text-muted mb-3">PRs with these labels will be skipped</p>
1136+
<div className="flex gap-2 mb-3">
1137+
<input
1138+
type="text"
1139+
value={excludeLabelInput}
1140+
onChange={(e) => setExcludeLabelInput(e.target.value)}
1141+
onKeyDown={(e) => e.key === 'Enter' && addExcludeLabel()}
1142+
placeholder="Add label (e.g., wip, draft)"
1143+
className="flex-1 px-4 py-2.5 bg-bg-card border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30 transition-all"
1144+
/>
1145+
<button
1146+
onClick={addExcludeLabel}
1147+
disabled={!excludeLabelInput.trim()}
1148+
className="px-4 py-2.5 bg-accent-cyan/10 border border-accent-cyan/30 text-accent-cyan rounded-lg text-sm font-medium hover:bg-accent-cyan/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
1149+
>
1150+
Add
1151+
</button>
1152+
</div>
1153+
<div className="flex flex-wrap gap-2">
1154+
{prReviewConfig.excludeLabels.map((label) => (
1155+
<span
1156+
key={label}
1157+
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-400/10 border border-amber-400/30 text-amber-400 rounded-full text-xs font-medium"
1158+
>
1159+
{label}
1160+
<button
1161+
onClick={() => removeExcludeLabel(label)}
1162+
className="hover:text-amber-200 transition-colors"
1163+
>
1164+
<CloseIcon />
1165+
</button>
1166+
</span>
1167+
))}
1168+
</div>
1169+
</div>
1170+
1171+
{/* Exclude Authors */}
1172+
<div>
1173+
<label className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3 block">
1174+
Exclude Authors
1175+
</label>
1176+
<p className="text-xs text-text-muted mb-3">PRs from these authors will be skipped</p>
1177+
<div className="flex gap-2 mb-3">
1178+
<input
1179+
type="text"
1180+
value={excludeAuthorInput}
1181+
onChange={(e) => setExcludeAuthorInput(e.target.value)}
1182+
onKeyDown={(e) => e.key === 'Enter' && addExcludeAuthor()}
1183+
placeholder="Add author (e.g., dependabot[bot])"
1184+
className="flex-1 px-4 py-2.5 bg-bg-card border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30 transition-all"
1185+
/>
1186+
<button
1187+
onClick={addExcludeAuthor}
1188+
disabled={!excludeAuthorInput.trim()}
1189+
className="px-4 py-2.5 bg-accent-cyan/10 border border-accent-cyan/30 text-accent-cyan rounded-lg text-sm font-medium hover:bg-accent-cyan/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
1190+
>
1191+
Add
1192+
</button>
1193+
</div>
1194+
<div className="flex flex-wrap gap-2">
1195+
{prReviewConfig.excludeAuthors.map((author) => (
1196+
<span
1197+
key={author}
1198+
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-accent-purple/10 border border-accent-purple/30 text-accent-purple rounded-full text-xs font-medium"
1199+
>
1200+
{author}
1201+
<button
1202+
onClick={() => removeExcludeAuthor(author)}
1203+
className="hover:text-accent-purple/70 transition-colors"
1204+
>
1205+
<CloseIcon />
1206+
</button>
1207+
</span>
1208+
))}
1209+
</div>
1210+
</div>
1211+
1212+
{/* Max Files Changed */}
1213+
<div>
1214+
<label className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3 block">
1215+
Max Files Changed
1216+
</label>
1217+
<p className="text-xs text-text-muted mb-3">Skip PRs that change more than this many files</p>
1218+
<div className="flex items-center gap-4">
1219+
<input
1220+
type="range"
1221+
min="10"
1222+
max="200"
1223+
step="10"
1224+
value={prReviewConfig.maxFilesChanged}
1225+
onChange={(e) =>
1226+
setPrReviewConfig((prev) => ({
1227+
...prev,
1228+
maxFilesChanged: parseInt(e.target.value, 10),
1229+
}))
1230+
}
1231+
className="flex-1 h-2 bg-bg-hover rounded-full appearance-none cursor-pointer accent-accent-cyan"
1232+
/>
1233+
<div className="w-16 px-3 py-2 bg-bg-card border border-border-subtle rounded-lg text-center">
1234+
<span className="text-sm font-mono text-text-primary">
1235+
{prReviewConfig.maxFilesChanged}
1236+
</span>
1237+
</div>
1238+
</div>
1239+
</div>
1240+
</div>
1241+
)}
1242+
1243+
{/* Save Button */}
1244+
<div className="mt-6 pt-4 border-t border-border-subtle">
1245+
{prReviewError && (
1246+
<div className="mb-4 p-3 bg-error/10 border border-error/30 rounded-lg text-error text-sm flex items-center gap-2">
1247+
<AlertIcon />
1248+
{prReviewError}
1249+
</div>
1250+
)}
1251+
{prReviewSuccess && (
1252+
<div className="mb-4 p-3 bg-success/10 border border-success/30 rounded-lg text-success text-sm flex items-center gap-2">
1253+
<CheckIcon />
1254+
Settings saved successfully
1255+
</div>
1256+
)}
1257+
<ActionButton
1258+
onClick={handleSavePrReviewConfig}
1259+
disabled={prReviewLoading}
1260+
variant="primary"
1261+
icon={prReviewLoading ? <SpinnerIcon /> : <CheckIcon />}
1262+
fullWidth
1263+
>
1264+
{prReviewLoading ? 'Saving...' : 'Save Automation Settings'}
1265+
</ActionButton>
1266+
</div>
1267+
</div>
1268+
</div>
1269+
)}
1270+
9691271
{/* Custom Domain Section */}
9701272
{activeSection === 'domain' && (
9711273
<div className="space-y-8">
@@ -1366,3 +1668,52 @@ function SyncIcon({ spinning = false }: { spinning?: boolean } = {}) {
13661668
</svg>
13671669
);
13681670
}
1671+
1672+
function AutomationIcon() {
1673+
return (
1674+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1675+
<path d="M12 2v4" />
1676+
<path d="M12 18v4" />
1677+
<path d="M4.93 4.93l2.83 2.83" />
1678+
<path d="M16.24 16.24l2.83 2.83" />
1679+
<path d="M2 12h4" />
1680+
<path d="M18 12h4" />
1681+
<path d="M4.93 19.07l2.83-2.83" />
1682+
<path d="M16.24 7.76l2.83-2.83" />
1683+
</svg>
1684+
);
1685+
}
1686+
1687+
function PullRequestIcon() {
1688+
return (
1689+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-white">
1690+
<circle cx="18" cy="18" r="3" />
1691+
<circle cx="6" cy="6" r="3" />
1692+
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
1693+
<line x1="6" y1="9" x2="6" y2="21" />
1694+
</svg>
1695+
);
1696+
}
1697+
1698+
function Toggle({
1699+
checked,
1700+
onChange,
1701+
}: {
1702+
checked: boolean;
1703+
onChange: (value: boolean) => void;
1704+
}) {
1705+
return (
1706+
<button
1707+
onClick={() => onChange(!checked)}
1708+
className={`relative w-12 h-6 rounded-full transition-colors ${
1709+
checked ? 'bg-accent-cyan' : 'bg-bg-hover'
1710+
}`}
1711+
>
1712+
<span
1713+
className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${
1714+
checked ? 'translate-x-7' : 'translate-x-1'
1715+
}`}
1716+
/>
1717+
</button>
1718+
);
1719+
}

0 commit comments

Comments
 (0)