Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<AiProviderModeFieldGroup state={initial} onChange={onChange} />);
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);
});
});
Original file line number Diff line number Diff line change
@@ -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<GeneratorGateAiReviewState>) {
onChange(patchGeneratorGateAiReview(state, patch));
}

return (
<section
className="rounded-token border-hairline bg-card p-5"
aria-labelledby="ai-provider-mode-title"
>
<div className="space-y-1">
<h2 id="ai-provider-mode-title" className="font-display text-token-lg font-semibold">
AI provider mode
</h2>
<p className="text-token-xs text-muted-foreground">
Choose how dual-model review decisions are combined and which provider/model names to
write into <code className="font-mono">gate.aiReview</code> in your generated config.
</p>
</div>

<div className="mt-4">
<Callout variant="safety" title="API keys stay out of this form">
Provider API keys are configured via environment variables, encrypted key storage, or the
maintainer BYOK dashboard — never in generated{" "}
<code className="font-mono">.gittensory.yml</code> files. This field group only records
mode and model <em>names</em>.
</Callout>
</div>

<div className="mt-5 space-y-5">
<fieldset>
<legend className={labelClass}>Combine strategy</legend>
<RadioGroup
className="mt-3 grid gap-3"
value={combine}
onValueChange={(value) => patch({ combine: value as AiCombineStrategy })}
>
{COMBINE_OPTIONS.map((option) => (
<label
key={option.value}
className={cn(
"flex cursor-pointer gap-3 rounded-token border border-border bg-background/40 p-3 transition-colors",
combine === option.value && "border-mint/40 bg-mint/5",
)}
>
<RadioGroupItem value={option.value} aria-label={option.title} className="mt-0.5" />
<span className="min-w-0">
<span className="block font-mono text-token-sm font-medium text-foreground">
{option.title}
</span>
<span className="mt-1 block text-token-xs text-muted-foreground">
{option.description}
</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>

<div className="grid gap-3 sm:grid-cols-2">
<label className="block" htmlFor="ai-provider-mode-provider">
<span className={labelClass}>Provider</span>
<select
id="ai-provider-mode-provider"
value={provider}
onChange={(event) => patch({ provider: event.target.value as AiProvider })}
className={fieldClass}
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT)</option>
</select>
</label>
<label className="block" htmlFor="ai-provider-mode-model">
<span className={labelClass}>Model (optional)</span>
<input
id="ai-provider-mode-model"
value={model}
onChange={(event) => patch({ model: event.target.value })}
placeholder="default"
className={fieldClass}
/>
</label>
</div>
</div>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -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<GeneratorFormState>(INITIAL_STATE);

return (
<div className="space-y-6">
<AiProviderModeFieldGroup state={formState} onChange={setFormState} />
</div>
);
}
7 changes: 7 additions & 0 deletions apps/gittensory-ui/src/components/site/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Activity,
BarChart3,
ExternalLink,
FileCog,
FolderGit2,
LayoutGrid,
Loader2,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
46 changes: 46 additions & 0 deletions apps/gittensory-ui/src/lib/config-generator-form-state.ts
Original file line number Diff line number Diff line change
@@ -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<GeneratorGateAiReviewState>,
): 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,
};
}
Loading
Loading