From a01a107bb67c8b1a2736fd2e3ca06ab66a4e868f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:04:11 +0000 Subject: [PATCH] Add config management RPCs and a workflow editor in the web client Clients can now read and edit the server's TOML config instead of the user hand-editing ~/.config/codereviewserver.toml. Server: - GetConfig returns the config re-read from disk, plus the workflow type and filter registries so clients build their pickers from what this server actually supports. - UpdateConfig applies a partial change: omitted fields (and keys the server doesn't model) are carried over, while sending Workflows replaces the list. It validates the config the update would produce before touching disk, writes atomically with a .bak of the previous file, and reloads the running config. A rejected config comes back as okay=false with per-field errors rather than an RPC error, so clients can attach each message to the input that caused it. Validation is split along package ownership: config.Validate covers root-level settings and the fields every workflow needs, while workflows.ValidateWorkflows owns the type and filter registries. Tests guard those registries against drifting from filter_func_map and the MatchWorkflows switch. Web client: - Preferences (the gear icon) now has Appearance and Server Configuration tabs. The latter edits the global settings and the workflow list, with collapsible workflow cards, reordering, dropdowns for workflow types and filters (including inputs for filters that take an argument), and fields shown per the selected type. - Client-side validation mirrors the server's rules and blocks a save while anything is wrong; anything it misses comes back from the server attached to the same field. List and filter-argument fields keep what the user typed and are only tidied at save time. Docs: configuration.md gains an "Editing the Config from a Client" section with the validation rules, protocol.md documents both methods and their payloads, and clients.md / building_clients.md cover the UI and what a new client should build against. config.go picks up gofmt formatting alongside the struct tag changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SkT2ZH9t9TRYxwYd4CbHTr --- bun_client/frontend/src/App.tsx | 44 +- bun_client/frontend/src/api.ts | 38 + .../frontend/src/components/ConfigManager.tsx | 841 ++++++++++++++++++ bun_client/frontend/src/config_utils.test.ts | 303 +++++++ bun_client/frontend/src/config_utils.ts | 352 ++++++++ bun_client/server.ts | 9 + config/config.go | 106 +-- config/file.go | 210 +++++ config/file_test.go | 191 ++++ config/validate.go | 111 +++ config/validate_test.go | 117 +++ docs/building_clients.md | 8 + docs/clients.md | 11 + docs/configuration.md | 33 + docs/protocol.md | 169 ++++ server/config_rpc_test.go | 241 +++++ server/server.go | 248 ++++++ workflows/validate.go | 250 ++++++ workflows/validate_test.go | 176 ++++ 19 files changed, 3406 insertions(+), 52 deletions(-) create mode 100644 bun_client/frontend/src/components/ConfigManager.tsx create mode 100644 bun_client/frontend/src/config_utils.test.ts create mode 100644 bun_client/frontend/src/config_utils.ts create mode 100644 config/file.go create mode 100644 config/file_test.go create mode 100644 config/validate.go create mode 100644 config/validate_test.go create mode 100644 server/config_rpc_test.go create mode 100644 workflows/validate.go create mode 100644 workflows/validate_test.go diff --git a/bun_client/frontend/src/App.tsx b/bun_client/frontend/src/App.tsx index f159c18..f3e261d 100644 --- a/bun_client/frontend/src/App.tsx +++ b/bun_client/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import PRList from './components/PRList'; import Review from './components/Review'; import PluginOutput from './components/PluginOutput'; +import ConfigManager from './components/ConfigManager'; import { rpcCall } from './api'; import { useIsMobile } from './hooks/useMediaQuery'; import { @@ -29,6 +30,7 @@ function App() { const [view, setView] = useState<'LIST' | 'REVIEW' | 'PLUGIN_OUTPUT'>('LIST'); const [currentPR, setCurrentPR] = useState(null); const [showPrefs, setShowPrefs] = useState(false); + const [prefsTab, setPrefsTab] = useState<'appearance' | 'server'>('appearance'); const [navigating, setNavigating] = useState(false); const isMobile = useIsMobile(); const [theme, setTheme] = useState(() => { @@ -343,11 +345,51 @@ function App() { isOpen={showPrefs} onClose={() => setShowPrefs(false)} title="Preferences" - size="sm" + size="lg" >
+ {( + [ + ['appearance', 'Appearance'], + ['server', 'Server Configuration'], + ] as const + ).map(([tab, label]) => ( + + ))} +
+ + {prefsTab === 'server' && } + +
= { 'RPCHandler.GetPluginOutput': '/api/get-plugin-output', 'RPCHandler.RerunPlugins': '/api/rerun-plugins', 'RPCHandler.GetHunkContext': '/api/get-hunk-context', + 'RPCHandler.GetConfig': '/api/get-config', + 'RPCHandler.UpdateConfig': '/api/update-config', }; export interface GetHunkContextArgs { @@ -53,6 +57,40 @@ export async function getHunkContext(args: GetHunkContextArgs): Promise('RPCHandler.GetHunkContext', [args]); } +/** + * Fetches the server's TOML configuration along with the workflow type and + * filter registries the config editor builds its pickers from. + */ +export async function getConfig(): Promise { + return rpcCall('RPCHandler.GetConfig', [{}]); +} + +/** + * Saves a partial configuration change. Fields left out keep whatever is on + * disk; sending `Workflows` replaces the whole list. + * + * A config the server rejects comes back as `okay: false` with `errors` + * populated rather than as a thrown error — the file is left untouched. + */ +export async function updateConfig(args: UpdateConfigArgs): Promise { + return rpcCall('RPCHandler.UpdateConfig', [args]); +} + +export interface UpdateConfigArgs { + Repos?: string[]; + SleepDuration?: number; + JiraDomain?: string; + GithubUsername?: string; + RepoLocation?: string; + AutoWorktree?: boolean; + DesktopNotifications?: boolean; + SectionPriority?: Record; + SectionSorting?: Record; + Workflows?: WorkflowEntry[]; + ExperimentalLLMFileOrdering?: boolean; + ExperimentalLLMReviewEase?: boolean; +} + export async function rpcCall(method: string, params: any[]): Promise { const id = Date.now(); const specializedPath = SPECIALIZED_ENDPOINTS[method]; diff --git a/bun_client/frontend/src/components/ConfigManager.tsx b/bun_client/frontend/src/components/ConfigManager.tsx new file mode 100644 index 0000000..7bd96a2 --- /dev/null +++ b/bun_client/frontend/src/components/ConfigManager.tsx @@ -0,0 +1,841 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { MouseEvent, ReactNode } from 'react'; +import { getConfig, updateConfig } from '../api'; +import { + cleanDraft, + draftFromConfig, + emptyWorkflow, + joinFilter, + joinList, + problemsByWorkflow, + splitFilter, + splitList, + validateDraft, + NOTIFICATION_OPTIONS, + PR_STATE_OPTIONS, +} from '../config_utils'; +import type { + ConfigDraft, + ConfigValidationError, + FilterInfo, + WorkflowEntry, + WorkflowTypeInfo, +} from '../config_utils'; +import { + Button, + Input, + Select, + TextArea, + colors, + spacing, + borderRadius, + fontSize, +} from '../design'; + +/** + * Editor for the server's TOML configuration (~/.config/codereviewserver.toml). + * + * The workflow type and filter pickers are built from the registries the + * server sends with the config, so they always offer what this server can + * actually run. Validation happens twice: here for immediate feedback, and + * again on the server, which refuses to write a config it can't run. + */ +export default function ConfigManager() { + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(null); + const [workflowTypes, setWorkflowTypes] = useState([]); + const [filters, setFilters] = useState([]); + const [path, setPath] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(''); + const [serverProblems, setServerProblems] = useState([]); + const [status, setStatus] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + // Validation messages stay hidden until the first save attempt, so a + // half-filled new workflow isn't shouting at the user while they type. + const [showProblems, setShowProblems] = useState(false); + const [expanded, setExpanded] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const reply = await getConfig(); + const loaded = draftFromConfig(reply.config); + setDraft(loaded); + setSaved(loaded); + setWorkflowTypes(reply.workflow_types ?? []); + setFilters(reply.filters ?? []); + setPath(reply.path); + setServerProblems([]); + setShowProblems(false); + setStatus(reply.okay ? null : { kind: 'error', text: reply.message }); + } catch (e: unknown) { + setLoadError(errorMessage(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const clientProblems = useMemo( + () => (draft ? validateDraft(draft, workflowTypes, filters) : []), + [draft, workflowTypes, filters] + ); + // Server problems are cleared as soon as the draft changes, so a stale + // rejection doesn't linger next to a field the user already fixed. + const problems = clientProblems.length > 0 ? clientProblems : serverProblems; + const grouped = useMemo(() => problemsByWorkflow(problems), [problems]); + const dirty = useMemo( + () => !!draft && !!saved && JSON.stringify(draft) !== JSON.stringify(saved), + [draft, saved] + ); + + const updateDraft = (change: Partial) => { + setDraft(current => (current ? { ...current, ...change } : current)); + setServerProblems([]); + setStatus(null); + }; + + const updateWorkflow = (index: number, change: Partial) => { + setDraft(current => { + if (!current) return current; + const workflows = current.Workflows.map((workflow, i) => + i === index ? { ...workflow, ...change } : workflow + ); + return { ...current, Workflows: workflows }; + }); + setServerProblems([]); + setStatus(null); + }; + + const addWorkflow = () => { + if (!draft) return; + const next = [...draft.Workflows, emptyWorkflow(workflowTypes)]; + updateDraft({ Workflows: next }); + setExpanded(next.length - 1); + }; + + const removeWorkflow = (index: number) => { + if (!draft) return; + const workflow = draft.Workflows[index]; + const label = workflow.Name?.trim() || 'this workflow'; + if (!window.confirm(`Remove ${label}? Its PRs drop out of the list on the next sync.`)) { + return; + } + updateDraft({ Workflows: draft.Workflows.filter((_, i) => i !== index) }); + setExpanded(null); + }; + + const moveWorkflow = (index: number, delta: number) => { + if (!draft) return; + const target = index + delta; + if (target < 0 || target >= draft.Workflows.length) return; + const workflows = [...draft.Workflows]; + [workflows[index], workflows[target]] = [workflows[target], workflows[index]]; + updateDraft({ Workflows: workflows }); + setExpanded(target); + }; + + const save = async () => { + if (!draft) return; + setShowProblems(true); + if (clientProblems.length > 0) { + setStatus({ + kind: 'error', + text: `Fix ${clientProblems.length} problem${clientProblems.length === 1 ? '' : 's'} before saving.`, + }); + return; + } + + setSaving(true); + setStatus(null); + try { + const payload = cleanDraft(draft); + const reply = await updateConfig({ + Repos: payload.Repos, + SleepDuration: payload.SleepDuration, + GithubUsername: payload.GithubUsername, + RepoLocation: payload.RepoLocation, + JiraDomain: payload.JiraDomain, + AutoWorktree: payload.AutoWorktree, + DesktopNotifications: payload.DesktopNotifications, + Workflows: payload.Workflows, + }); + if (!reply.okay) { + setServerProblems(reply.errors ?? []); + setStatus({ + kind: 'error', + text: reply.message || 'The server rejected the configuration.', + }); + return; + } + const applied = draftFromConfig(reply.config); + setDraft(applied); + setSaved(applied); + setServerProblems([]); + setPath(reply.path); + setStatus({ + kind: 'ok', + text: `${reply.message}. Workflow changes take effect on the next sync.`, + }); + } catch (e: unknown) { + setStatus({ kind: 'error', text: errorMessage(e) }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading configuration…
; + } + if (loadError || !draft) { + return ( +
+
+ Could not load the configuration: {loadError} +
+
+ +
+
+ ); + } + + const globalProblems = showProblems ? (grouped.get(-1) ?? []) : []; + + return ( +
+
+ Editing {path}. The previous file is kept alongside it as{' '} + .bak; comments in the file are not preserved. +
+ +
+ Global Settings +