diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.test.tsx new file mode 100644 index 0000000000..112d9ba342 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AiProviderModeFieldGroup } from "@/components/site/app-panels/ai-provider-mode-field-group"; +import type { GeneratorFormState } from "@/lib/config-generator-form-state"; +import { gateAiReviewManifestPatch } from "@/lib/config-generator-form-state"; + +const SECRET_PATTERN = /api[_-]?key|secret|password|sk-ant-|sk-[a-z]/i; + +function renderGroup(initial: GeneratorFormState = {}, onChange = vi.fn()) { + const view = render(); + return { onChange, ...view }; +} + +describe("AiProviderModeFieldGroup", () => { + it("renders each combine strategy option and patches state on selection", () => { + const onChange = vi.fn(); + renderGroup({}, onChange); + + expect(screen.getByRole("radio", { name: "single" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "consensus" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "synthesis" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("radio", { name: "consensus" })); + expect(onChange).toHaveBeenCalledWith({ + gate: { aiReview: { combine: "consensus" } }, + }); + + fireEvent.click(screen.getByRole("radio", { name: "synthesis" })); + expect(onChange).toHaveBeenLastCalledWith({ + gate: { aiReview: { combine: "synthesis" } }, + }); + }); + + it("edits provider and model fields into gate.aiReview without any secret inputs", () => { + const onChange = vi.fn(); + renderGroup({ gate: { aiReview: { combine: "single" } } }, onChange); + + expect(screen.queryByLabelText(/api key/i)).toBeNull(); + expect(screen.queryByPlaceholderText(/sk-/i)).toBeNull(); + expect(screen.queryByDisplayValue(/sk-/i)).toBeNull(); + expect(document.querySelector('input[type="password"]')).toBeNull(); + + fireEvent.change(screen.getByLabelText(/^provider$/i), { target: { value: "openai" } }); + expect(onChange).toHaveBeenCalledWith({ + gate: { aiReview: { combine: "single", provider: "openai" } }, + }); + + fireEvent.change(screen.getByLabelText(/^model/i), { target: { value: "gpt-4.1-mini" } }); + expect(onChange).toHaveBeenLastCalledWith({ + gate: { aiReview: { combine: "single", model: "gpt-4.1-mini" } }, + }); + + const emitted = JSON.stringify(onChange.mock.calls); + expect(emitted).not.toMatch(SECRET_PATTERN); + }); + + it("maps emitted state to manifest keys with no secret fields", () => { + const state: GeneratorFormState = { + gate: { + aiReview: { + combine: "synthesis", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + }, + }, + }; + const patch = gateAiReviewManifestPatch(state.gate?.aiReview); + expect(patch).toEqual({ + aiReviewCombine: "synthesis", + aiReviewProvider: "anthropic", + aiReviewModel: "claude-sonnet-4-20250514", + }); + expect(JSON.stringify(patch)).not.toMatch(SECRET_PATTERN); + expect(Object.keys(patch)).not.toContain("apiKey"); + expect(Object.keys(patch)).not.toContain("key"); + }); + + it("shows the secret-handling callout and never renders a secret field in the DOM", () => { + renderGroup(); + expect(screen.getByText(/API keys stay out of this form/i)).toBeTruthy(); + expect(screen.getByText(/environment variables/i)).toBeTruthy(); + expect(document.body.textContent).not.toMatch(SECRET_PATTERN); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx new file mode 100644 index 0000000000..1a9dea2e1d --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx @@ -0,0 +1,131 @@ +import { Callout } from "@/components/site/primitives"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import type { + AiCombineStrategy, + AiProvider, + GeneratorFormState, + GeneratorGateAiReviewState, +} from "@/lib/config-generator-form-state"; +import { patchGeneratorGateAiReview } from "@/lib/config-generator-form-state"; +import { cn } from "@/lib/utils"; + +const COMBINE_OPTIONS: Array<{ value: AiCombineStrategy; title: string; description: string }> = [ + { + value: "single", + title: "single", + description: "One reviewer verdict. Default for one provider or a fallback chain.", + }, + { + value: "consensus", + title: "consensus", + description: "Block only when both reviewers flag a critical defect.", + }, + { + value: "synthesis", + title: "synthesis", + description: "Both reviewers run, then one merged decision is produced.", + }, +]; + +const fieldClass = + "mt-1 min-h-10 w-full rounded-token border border-border bg-background/70 px-3 py-2 font-mono text-token-sm text-foreground outline-none transition-colors focus:border-mint"; +const labelClass = "font-mono text-token-2xs uppercase tracking-wider text-muted-foreground"; + +export function AiProviderModeFieldGroup({ + state, + onChange, +}: { + state: GeneratorFormState; + onChange: (next: GeneratorFormState) => void; +}) { + const aiReview = state.gate?.aiReview ?? {}; + const combine = aiReview.combine ?? "single"; + const provider = aiReview.provider ?? "anthropic"; + const model = aiReview.model ?? ""; + + function patch(patch: Partial) { + onChange(patchGeneratorGateAiReview(state, patch)); + } + + return ( +
+
+

+ AI provider mode +

+

+ Choose how dual-model review decisions are combined and which provider/model names to + write into gate.aiReview in your generated config. +

+
+ +
+ + Provider API keys are configured via environment variables, encrypted key storage, or the + maintainer BYOK dashboard — never in generated{" "} + .gittensory.yml files. This field group only records + mode and model names. + +
+ +
+
+ Combine strategy + patch({ combine: value as AiCombineStrategy })} + > + {COMBINE_OPTIONS.map((option) => ( + + ))} + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/config-generator-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/config-generator-panel.tsx new file mode 100644 index 0000000000..896c5b9616 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/config-generator-panel.tsx @@ -0,0 +1,16 @@ +import { useState } from "react"; + +import { AiProviderModeFieldGroup } from "@/components/site/app-panels/ai-provider-mode-field-group"; +import type { GeneratorFormState } from "@/lib/config-generator-form-state"; + +const INITIAL_STATE: GeneratorFormState = {}; + +export function ConfigGeneratorPanel() { + const [formState, setFormState] = useState(INITIAL_STATE); + + return ( +
+ +
+ ); +} diff --git a/apps/gittensory-ui/src/components/site/app-shell.tsx b/apps/gittensory-ui/src/components/site/app-shell.tsx index 9b63ccd34a..51322b7b6c 100644 --- a/apps/gittensory-ui/src/components/site/app-shell.tsx +++ b/apps/gittensory-ui/src/components/site/app-shell.tsx @@ -3,6 +3,7 @@ import { Activity, BarChart3, ExternalLink, + FileCog, FolderGit2, LayoutGrid, Loader2, @@ -63,6 +64,12 @@ const GROUPS: NavGroup[] = [ icon: FolderGit2, roles: ["maintainer", "owner", "operator"], }, + { + to: "/app/config-generator", + label: "Config generator", + icon: FileCog, + roles: ["maintainer", "owner", "operator"], + }, { to: "/app/runs", label: "Agent runs", diff --git a/apps/gittensory-ui/src/components/site/command-palette.tsx b/apps/gittensory-ui/src/components/site/command-palette.tsx index a9fc99e775..9242c5e7ab 100644 --- a/apps/gittensory-ui/src/components/site/command-palette.tsx +++ b/apps/gittensory-ui/src/components/site/command-palette.tsx @@ -20,6 +20,7 @@ const DEFAULT_ITEMS: PaletteItem[] = [ { label: "Overview", to: "/app", group: "App" }, { label: "Miner command center", to: "/app/miner", group: "App" }, { label: "Maintainer console", to: "/app/maintainer", group: "App" }, + { label: "Config generator", to: "/app/config-generator", group: "App" }, { label: "Repo owner workspace", to: "/app/owner", group: "App" }, { label: "Agent runs", to: "/app/runs", group: "App" }, { label: "Agent playground", to: "/app/playground", group: "App" }, diff --git a/apps/gittensory-ui/src/lib/config-generator-form-state.ts b/apps/gittensory-ui/src/lib/config-generator-form-state.ts new file mode 100644 index 0000000000..b84f17f094 --- /dev/null +++ b/apps/gittensory-ui/src/lib/config-generator-form-state.ts @@ -0,0 +1,46 @@ +/** Typed form state for the config generator (#1683). Field groups append slices here; YAML preview (#2210) serializes later. */ + +export type AiCombineStrategy = "single" | "consensus" | "synthesis"; +export type AiProvider = "anthropic" | "openai"; + +export type GeneratorGateAiReviewState = { + combine?: AiCombineStrategy | null; + provider?: AiProvider | null; + model?: string | null; +}; + +export type GeneratorFormState = { + gate?: { + aiReview?: GeneratorGateAiReviewState; + }; +}; + +export function patchGeneratorGateAiReview( + state: GeneratorFormState, + patch: Partial, +): GeneratorFormState { + return { + ...state, + gate: { + ...state.gate, + aiReview: { + ...state.gate?.aiReview, + ...patch, + }, + }, + }; +} + +/** Map the AI-provider slice to manifest gate keys (gate.aiReview.* in focus-manifest). */ +export function gateAiReviewManifestPatch(aiReview: GeneratorGateAiReviewState | undefined): { + aiReviewCombine: AiCombineStrategy | null; + aiReviewProvider: AiProvider | null; + aiReviewModel: string | null; +} { + const model = aiReview?.model?.trim(); + return { + aiReviewCombine: aiReview?.combine ?? null, + aiReviewProvider: aiReview?.provider ?? null, + aiReviewModel: model ? model : null, + }; +} diff --git a/apps/gittensory-ui/src/routeTree.gen.ts b/apps/gittensory-ui/src/routeTree.gen.ts index cc373ea43a..089a069454 100644 --- a/apps/gittensory-ui/src/routeTree.gen.ts +++ b/apps/gittensory-ui/src/routeTree.gen.ts @@ -64,6 +64,7 @@ import { Route as AppOperatorRouteImport } from './routes/app.operator' import { Route as AppMinerRouteImport } from './routes/app.miner' import { Route as AppMaintainerRouteImport } from './routes/app.maintainer' import { Route as AppDigestRouteImport } from './routes/app.digest' +import { Route as AppConfigGeneratorRouteImport } from './routes/app.config-generator' import { Route as AppCommandsRouteImport } from './routes/app.commands' import { Route as AppAuditRouteImport } from './routes/app.audit' import { Route as AppAnalyticsRouteImport } from './routes/app.analytics' @@ -357,6 +358,11 @@ const AppDigestRoute = AppDigestRouteImport.update({ path: '/digest', getParentRoute: () => AppRoute, } as any) +const AppConfigGeneratorRoute = AppConfigGeneratorRouteImport.update({ + id: '/config-generator', + path: '/config-generator', + getParentRoute: () => AppRoute, +} as any) const AppCommandsRoute = AppCommandsRouteImport.update({ id: '/commands', path: '/commands', @@ -398,6 +404,7 @@ export interface FileRoutesByFullPath { '/app/analytics': typeof AppAnalyticsRoute '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute + '/app/config-generator': typeof AppConfigGeneratorRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute '/app/miner': typeof AppMinerRoute @@ -457,6 +464,7 @@ export interface FileRoutesByTo { '/app/analytics': typeof AppAnalyticsRoute '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute + '/app/config-generator': typeof AppConfigGeneratorRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute '/app/miner': typeof AppMinerRoute @@ -520,6 +528,7 @@ export interface FileRoutesById { '/app/analytics': typeof AppAnalyticsRoute '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute + '/app/config-generator': typeof AppConfigGeneratorRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute '/app/miner': typeof AppMinerRoute @@ -584,6 +593,7 @@ export interface FileRouteTypes { | '/app/analytics' | '/app/audit' | '/app/commands' + | '/app/config-generator' | '/app/digest' | '/app/maintainer' | '/app/miner' @@ -643,6 +653,7 @@ export interface FileRouteTypes { | '/app/analytics' | '/app/audit' | '/app/commands' + | '/app/config-generator' | '/app/digest' | '/app/maintainer' | '/app/miner' @@ -705,6 +716,7 @@ export interface FileRouteTypes { | '/app/analytics' | '/app/audit' | '/app/commands' + | '/app/config-generator' | '/app/digest' | '/app/maintainer' | '/app/miner' @@ -1154,6 +1166,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppDigestRouteImport parentRoute: typeof AppRoute } + '/app/config-generator': { + id: '/app/config-generator' + path: '/config-generator' + fullPath: '/app/config-generator' + preLoaderRoute: typeof AppConfigGeneratorRouteImport + parentRoute: typeof AppRoute + } '/app/commands': { id: '/app/commands' path: '/commands' @@ -1208,6 +1227,7 @@ interface AppRouteChildren { AppAnalyticsRoute: typeof AppAnalyticsRoute AppAuditRoute: typeof AppAuditRoute AppCommandsRoute: typeof AppCommandsRoute + AppConfigGeneratorRoute: typeof AppConfigGeneratorRoute AppDigestRoute: typeof AppDigestRoute AppMaintainerRoute: typeof AppMaintainerRoute AppMinerRoute: typeof AppMinerRoute @@ -1224,6 +1244,7 @@ const AppRouteChildren: AppRouteChildren = { AppAnalyticsRoute: AppAnalyticsRoute, AppAuditRoute: AppAuditRoute, AppCommandsRoute: AppCommandsRoute, + AppConfigGeneratorRoute: AppConfigGeneratorRoute, AppDigestRoute: AppDigestRoute, AppMaintainerRoute: AppMaintainerRoute, AppMinerRoute: AppMinerRoute, diff --git a/apps/gittensory-ui/src/routes/app.config-generator.tsx b/apps/gittensory-ui/src/routes/app.config-generator.tsx new file mode 100644 index 0000000000..5c329857d9 --- /dev/null +++ b/apps/gittensory-ui/src/routes/app.config-generator.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConfigGeneratorPanel } from "@/components/site/app-panels/config-generator-panel"; +import { PageHeader } from "@/components/site/primitives"; + +export const Route = createFileRoute("/app/config-generator")({ + component: ConfigGeneratorRoute, +}); + +function ConfigGeneratorRoute() { + return ( +
+ + +
+ ); +}