11import { useState , useEffect } from 'react'
2- import { Save , RefreshCw , Check , ChevronDown , ChevronRight , Eye , EyeOff , GitPullRequestDraft } from 'lucide-react'
2+ import { Save , RefreshCw , Check , ChevronDown , ChevronRight , Eye , EyeOff , GitPullRequestDraft , Plus , Trash2 } from 'lucide-react'
33import { useConfig , useUpdateConfig , useAgentTools } from '../api/hooks'
44import { api } from '../api/client'
55import { MODEL_PRESETS } from '../lib/models'
@@ -100,6 +100,99 @@ interface PatternRepositoryFormState {
100100 max_rules ?: number
101101}
102102
103+ interface PathInstructionFormState {
104+ path : string
105+ review_instructions : string
106+ preserved : Record < string , unknown >
107+ }
108+
109+ interface CustomContextFormState {
110+ scope : string
111+ notesText : string
112+ filesText : string
113+ }
114+
115+ function asRecord ( value : unknown ) : Record < string , unknown > {
116+ return value && typeof value === 'object' && ! Array . isArray ( value )
117+ ? value as Record < string , unknown >
118+ : { }
119+ }
120+
121+ function splitLines ( value : string ) : string [ ] {
122+ return value
123+ . split ( '\n' )
124+ . map ( line => line . trim ( ) )
125+ . filter ( Boolean )
126+ }
127+
128+ function parsePathInstructionEntries ( form : Record < string , unknown > ) : PathInstructionFormState [ ] {
129+ const paths = asRecord ( form . paths )
130+
131+ return Object . entries ( paths ) . flatMap ( ( [ path , candidate ] ) : PathInstructionFormState [ ] => {
132+ const preserved = asRecord ( candidate )
133+ return [ {
134+ path,
135+ review_instructions : typeof preserved . review_instructions === 'string' ? preserved . review_instructions : '' ,
136+ preserved,
137+ } ]
138+ } )
139+ }
140+
141+ function buildPathsValue ( entries : PathInstructionFormState [ ] ) : Record < string , unknown > {
142+ const next : Record < string , unknown > = { }
143+
144+ for ( const entry of entries ) {
145+ const path = entry . path . trim ( )
146+ if ( ! path ) continue
147+
148+ const value = { ...entry . preserved }
149+ const reviewInstructions = entry . review_instructions . trim ( )
150+ if ( reviewInstructions ) value . review_instructions = reviewInstructions
151+ else delete value . review_instructions
152+
153+ if ( Object . keys ( value ) . length > 0 ) {
154+ next [ path ] = value
155+ }
156+ }
157+
158+ return next
159+ }
160+
161+ function parseCustomContextEntries ( form : Record < string , unknown > ) : CustomContextFormState [ ] {
162+ const value = Array . isArray ( form . custom_context ) ? form . custom_context : [ ]
163+
164+ return value . flatMap ( ( entry ) : CustomContextFormState [ ] => {
165+ const candidate = asRecord ( entry )
166+ return [ {
167+ scope : typeof candidate . scope === 'string' ? candidate . scope : '' ,
168+ notesText : Array . isArray ( candidate . notes )
169+ ? candidate . notes . filter ( ( item ) : item is string => typeof item === 'string' ) . join ( '\n' )
170+ : '' ,
171+ filesText : Array . isArray ( candidate . files )
172+ ? candidate . files . filter ( ( item ) : item is string => typeof item === 'string' ) . join ( '\n' )
173+ : '' ,
174+ } ]
175+ } )
176+ }
177+
178+ function buildCustomContextValue ( entries : CustomContextFormState [ ] ) : Record < string , unknown > [ ] {
179+ return entries . flatMap ( ( entry ) : Record < string , unknown > [ ] => {
180+ const scope = entry . scope . trim ( )
181+ const notes = splitLines ( entry . notesText )
182+ const files = splitLines ( entry . filesText )
183+
184+ if ( ! scope && notes . length === 0 && files . length === 0 ) {
185+ return [ ]
186+ }
187+
188+ return [ {
189+ scope : scope || undefined ,
190+ notes,
191+ files,
192+ } ]
193+ } )
194+ }
195+
103196type ProvidersMap = Record < string , ProviderFormState >
104197
105198function getProviders ( form : Record < string , unknown > ) : ProvidersMap {
@@ -153,6 +246,8 @@ export function Settings() {
153246 const updateConfig = useUpdateConfig ( )
154247 const { data : agentTools } = useAgentTools ( )
155248 const [ form , setForm ] = useState < Record < string , unknown > > ( { } )
249+ const [ pathInstructionEntries , setPathInstructionEntries ] = useState < PathInstructionFormState [ ] > ( [ ] )
250+ const [ customContextEntries , setCustomContextEntries ] = useState < CustomContextFormState [ ] > ( [ ] )
156251 const [ saved , setSaved ] = useState ( false )
157252 const [ activeTab , setActiveTab ] = useState < TabId > ( ( ) => {
158253 const hash = window . location . hash . replace ( '#' , '' ) as TabId
@@ -170,15 +265,26 @@ export function Settings() {
170265 const [ ghLoading , setGhLoading ] = useState ( false )
171266
172267 useEffect ( ( ) => {
173- if ( config ) setForm ( config )
268+ if ( config ) {
269+ setForm ( config )
270+ setPathInstructionEntries ( parsePathInstructionEntries ( config ) )
271+ setCustomContextEntries ( parseCustomContextEntries ( config ) )
272+ }
174273 } , [ config ] )
175274
176275 useEffect ( ( ) => {
177276 window . location . hash = activeTab
178277 } , [ activeTab ] )
179278
180279 const handleSave = ( ) => {
181- updateConfig . mutate ( form , {
280+ const nextForm = {
281+ ...form ,
282+ paths : buildPathsValue ( pathInstructionEntries ) ,
283+ custom_context : buildCustomContextValue ( customContextEntries ) ,
284+ }
285+
286+ setForm ( nextForm )
287+ updateConfig . mutate ( nextForm , {
182288 onSuccess : ( ) => {
183289 setSaved ( true )
184290 setTimeout ( ( ) => setSaved ( false ) , 2000 )
@@ -320,6 +426,42 @@ export function Settings() {
320426 setForm ( { ...form , pattern_repositories : nextRepositories } )
321427 }
322428
429+ const updatePathInstructionEntry = ( index : number , patch : Partial < PathInstructionFormState > ) => {
430+ setPathInstructionEntries ( entries => entries . map ( ( entry , entryIndex ) => (
431+ entryIndex === index ? { ...entry , ...patch } : entry
432+ ) ) )
433+ }
434+
435+ const addPathInstructionEntry = ( ) => {
436+ setPathInstructionEntries ( entries => [ ...entries , {
437+ path : '' ,
438+ review_instructions : '' ,
439+ preserved : { } ,
440+ } ] )
441+ }
442+
443+ const removePathInstructionEntry = ( index : number ) => {
444+ setPathInstructionEntries ( entries => entries . filter ( ( _ , entryIndex ) => entryIndex !== index ) )
445+ }
446+
447+ const updateCustomContextEntry = ( index : number , patch : Partial < CustomContextFormState > ) => {
448+ setCustomContextEntries ( entries => entries . map ( ( entry , entryIndex ) => (
449+ entryIndex === index ? { ...entry , ...patch } : entry
450+ ) ) )
451+ }
452+
453+ const addCustomContextEntry = ( ) => {
454+ setCustomContextEntries ( entries => [ ...entries , {
455+ scope : '' ,
456+ notesText : '' ,
457+ filesText : '' ,
458+ } ] )
459+ }
460+
461+ const removeCustomContextEntry = ( index : number ) => {
462+ setCustomContextEntries ( entries => entries . filter ( ( _ , entryIndex ) => entryIndex !== index ) )
463+ }
464+
323465 const toggleStringArrayField = ( key : string , value : string ) => {
324466 const current = stringArrayField ( key )
325467 const next = current . includes ( value )
@@ -583,6 +725,150 @@ export function Settings() {
583725 One source per line. Existing scope and rule-pattern settings are preserved for matching sources; newly added sources use default limits until advanced editing is surfaced.
584726 </ p >
585727 </ div >
728+ < div className = "rounded-lg border border-border-subtle bg-surface p-3" >
729+ < div className = "flex items-center justify-between gap-3 mb-3" >
730+ < div >
731+ < div className = "text-[12px] font-medium text-text-secondary" > Per-path Review Instructions</ div >
732+ < p className = "text-[10px] text-text-muted mt-1" >
733+ Override reviewer guidance for common repo areas like `tests/**`, `scripts/**`, or `src/auth/**`.
734+ </ p >
735+ </ div >
736+ < button
737+ onClick = { addPathInstructionEntry }
738+ className = "flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium bg-accent/10 text-accent border border-accent/20 hover:bg-accent/15 transition-colors"
739+ >
740+ < Plus size = { 12 } />
741+ Add path
742+ </ button >
743+ </ div >
744+
745+ < div className = "space-y-3" >
746+ { pathInstructionEntries . length === 0 ? (
747+ < div className = "text-[11px] text-text-muted border border-dashed border-border rounded px-3 py-3" >
748+ No path-specific instructions yet.
749+ </ div >
750+ ) : pathInstructionEntries . map ( ( entry , index ) => {
751+ const preservesExtraFields = Object . keys ( entry . preserved ) . some ( key => key !== 'review_instructions' )
752+
753+ return (
754+ < div key = { `path-instruction-${ index } ` } className = "rounded border border-border bg-surface-1 p-3 space-y-3" >
755+ < div className = "flex items-start gap-2" >
756+ < div className = "flex-1 space-y-1.5" >
757+ < label className = "block text-[11px] font-medium text-text-secondary" > Path Pattern</ label >
758+ < input
759+ type = "text"
760+ value = { entry . path }
761+ onChange = { ( e ) => updatePathInstructionEntry ( index , { path : e . target . value } ) }
762+ placeholder = "tests/**"
763+ className = "w-full bg-surface border border-border rounded px-3 py-1.5 text-[13px] text-text-primary placeholder:text-text-muted/30 focus:outline-none focus:ring-1 focus:ring-accent font-code"
764+ />
765+ </ div >
766+ < button
767+ onClick = { ( ) => removePathInstructionEntry ( index ) }
768+ className = "mt-6 p-1.5 rounded text-text-muted hover:text-sev-error hover:bg-sev-error/10 transition-colors"
769+ title = "Remove path instruction"
770+ >
771+ < Trash2 size = { 14 } />
772+ </ button >
773+ </ div >
774+
775+ < div >
776+ < label className = "block text-[11px] font-medium text-text-secondary mb-1" > Review Instructions</ label >
777+ < textarea
778+ value = { entry . review_instructions }
779+ onChange = { ( e ) => updatePathInstructionEntry ( index , { review_instructions : e . target . value } ) }
780+ placeholder = "Focus on flaky tests, auth boundaries, and tenant isolation."
781+ rows = { 3 }
782+ className = "w-full bg-surface border border-border rounded px-3 py-2 text-[13px] text-text-primary placeholder:text-text-muted/30 focus:outline-none focus:ring-1 focus:ring-accent font-code resize-y"
783+ />
784+ < p className = "text-[10px] text-text-muted mt-1" >
785+ Applied only when changed files match this path expression.
786+ </ p >
787+ </ div >
788+
789+ { preservesExtraFields && (
790+ < div className = "text-[10px] text-text-muted font-code" >
791+ Existing focus/context overrides for this path will be preserved.
792+ </ div >
793+ ) }
794+ </ div >
795+ )
796+ } ) }
797+ </ div >
798+ </ div >
799+
800+ < div className = "rounded-lg border border-border-subtle bg-surface p-3" >
801+ < div className = "flex items-center justify-between gap-3 mb-3" >
802+ < div >
803+ < div className = "text-[12px] font-medium text-text-secondary" > Custom Context</ div >
804+ < p className = "text-[10px] text-text-muted mt-1" >
805+ Attach scoped notes and reference files so repeat guidance can be reused without editing raw config.
806+ </ p >
807+ </ div >
808+ < button
809+ onClick = { addCustomContextEntry }
810+ className = "flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium bg-accent/10 text-accent border border-accent/20 hover:bg-accent/15 transition-colors"
811+ >
812+ < Plus size = { 12 } />
813+ Add context
814+ </ button >
815+ </ div >
816+
817+ < div className = "space-y-3" >
818+ { customContextEntries . length === 0 ? (
819+ < div className = "text-[11px] text-text-muted border border-dashed border-border rounded px-3 py-3" >
820+ No scoped context notes configured yet.
821+ </ div >
822+ ) : customContextEntries . map ( ( entry , index ) => (
823+ < div key = { `custom-context-${ index } ` } className = "rounded border border-border bg-surface-1 p-3 space-y-3" >
824+ < div className = "flex items-start gap-2" >
825+ < div className = "flex-1 space-y-1.5" >
826+ < label className = "block text-[11px] font-medium text-text-secondary" > Scope</ label >
827+ < input
828+ type = "text"
829+ value = { entry . scope }
830+ onChange = { ( e ) => updateCustomContextEntry ( index , { scope : e . target . value } ) }
831+ placeholder = "auth|payments|docs/**"
832+ className = "w-full bg-surface border border-border rounded px-3 py-1.5 text-[13px] text-text-primary placeholder:text-text-muted/30 focus:outline-none focus:ring-1 focus:ring-accent font-code"
833+ />
834+ </ div >
835+ < button
836+ onClick = { ( ) => removeCustomContextEntry ( index ) }
837+ className = "mt-6 p-1.5 rounded text-text-muted hover:text-sev-error hover:bg-sev-error/10 transition-colors"
838+ title = "Remove custom context"
839+ >
840+ < Trash2 size = { 14 } />
841+ </ button >
842+ </ div >
843+
844+ < div >
845+ < label className = "block text-[11px] font-medium text-text-secondary mb-1" > Notes</ label >
846+ < textarea
847+ value = { entry . notesText }
848+ onChange = { ( e ) => updateCustomContextEntry ( index , { notesText : e . target . value } ) }
849+ placeholder = { 'Prefer tenant-safe queries\nKeep auth audit logs intact' }
850+ rows = { 3 }
851+ className = "w-full bg-surface border border-border rounded px-3 py-2 text-[13px] text-text-primary placeholder:text-text-muted/30 focus:outline-none focus:ring-1 focus:ring-accent font-code resize-y"
852+ />
853+ < p className = "text-[10px] text-text-muted mt-1" > One reusable note per line.</ p >
854+ </ div >
855+
856+ < div >
857+ < label className = "block text-[11px] font-medium text-text-secondary mb-1" > Files</ label >
858+ < textarea
859+ value = { entry . filesText }
860+ onChange = { ( e ) => updateCustomContextEntry ( index , { filesText : e . target . value } ) }
861+ placeholder = { 'docs/auth.md\nrfc/tenant-isolation.md' }
862+ rows = { 3 }
863+ className = "w-full bg-surface border border-border rounded px-3 py-2 text-[13px] text-text-primary placeholder:text-text-muted/30 focus:outline-none focus:ring-1 focus:ring-accent font-code resize-y"
864+ />
865+ < p className = "text-[10px] text-text-muted mt-1" > Reference files or globs to pull into the scoped context.</ p >
866+ </ div >
867+ </ div >
868+ ) ) }
869+ </ div >
870+ </ div >
871+
586872 { field ( 'Max Active Rules' , 'max_active_rules' , 'number' , '32' , 'Upper bound for active rule loading across repository-local and shared rule sources' ) }
587873 </ div >
588874 </ Section >
0 commit comments