@@ -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