Skip to content

Commit 719cecb

Browse files
committed
feat: add structured settings review context editors
1 parent 5315649 commit 719cecb

File tree

3 files changed

+417
-5
lines changed

3 files changed

+417
-5
lines changed

TODO.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
7676

7777
41. [x] Surface pattern repository sources in the Settings UI with validation and defaults.
7878
42. [x] Surface review rule file sources in the Settings UI instead of requiring config edits by hand.
79-
43. [ ] Add structured UI editing for custom context notes, files, and scopes.
80-
44. [ ] Add per-path scoped review instructions in the Settings UI for common repo areas.
79+
43. [x] Add structured UI editing for custom context notes, files, and scopes.
80+
44. [x] Add per-path scoped review instructions in the Settings UI for common repo areas.
8181
45. [ ] Support Jira/Linear issue context ingestion for PR-linked reviews.
8282
46. [ ] Support document-backed context ingestion for design docs, RFCs, and runbooks.
8383
47. [ ] Add explicit "intent mismatch" review checks comparing PR changes to ticket acceptance criteria.
@@ -166,4 +166,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
166166
- [x] Add file-level readiness summaries to the review diff sidebar.
167167
- [x] Add visible feedback badges on comments so accepted and rejected states are not icon-only.
168168
- [x] Add a train-the-reviewer callout on review detail when thumbs coverage is low.
169+
- [x] Add structured custom context and per-path instruction editors to the Settings review context workflow.
169170
- [ ] Commit and push each validated checkpoint before moving to the next epic.

web/src/pages/Settings.tsx

Lines changed: 289 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { 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'
33
import { useConfig, useUpdateConfig, useAgentTools } from '../api/hooks'
44
import { api } from '../api/client'
55
import { 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+
103196
type ProvidersMap = Record<string, ProviderFormState>
104197

105198
function 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

Comments
 (0)