From bbfd5abfe2a33e9e339a2d8706a43f800bfcc1cd Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 21 Jul 2026 23:39:58 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Frontend=20=E2=80=94=20input=20pick?= =?UTF-8?q?er=20in=20NewAgentPanel=20+=20preview=20dialog=20enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/fixtures.mjs | 47 +++++ apps/web/e2e/fleet.spec.ts | 40 +++++ apps/web/e2e/mock-server.mjs | 11 ++ apps/web/src/app/McpCatalogDialog.tsx | 20 +-- apps/web/src/app/theme.css | 29 ++++ apps/web/src/lib/api.ts | 5 + apps/web/src/lib/archetypeConfig.test.ts | 164 ++++++++++++++++++ apps/web/src/lib/archetypeConfig.ts | 109 ++++++++++++ apps/web/src/lib/mcpTemplate.test.ts | 45 +++++ apps/web/src/lib/mcpTemplate.ts | 20 +++ apps/web/src/lib/types.ts | 6 + apps/web/src/settings/NewAgentPanel.tsx | 108 ++++++++++-- apps/web/src/setup/ArchetypePreviewDialog.tsx | 13 ++ 13 files changed, 588 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/archetypeConfig.test.ts create mode 100644 apps/web/src/lib/archetypeConfig.ts create mode 100644 apps/web/src/lib/mcpTemplate.test.ts create mode 100644 apps/web/src/lib/mcpTemplate.ts diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 3bbd4cf81..1109a3b4b 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -118,6 +118,53 @@ export const ARCHETYPES = [ { id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" }, ]; +// GET /api/archetypes/{id}/preview — the read-only bundle peek (#2041). product-stack asks +// for a GitHub MCP server (needs a token) + a standalone Brave secret, so it exercises the +// enriched preview dialog AND the new-agent Configure step. Code-free ids return bundle:null. +export const ARCHETYPE_PREVIEWS = { + "product-stack": { + id: "product-stack", + bundle: { + kind: "bundle", + id: "product-stack", + name: "Product Manager", + description: "Research, strategy, and specs.", + enabled: ["github"], + members: [ + { + id: "github", + builtin: false, + ref: "v1.2.0", + name: "GitHub", + version: "1.2.0", + description: "GitHub issues + PR tools.", + skills: [{ name: "pr-review", description: "Review a pull request" }], + }, + ], + mcp: [ + { + id: "github", + name: "GitHub", + category: "Dev", + tagline: "GitHub issues + PRs over MCP.", + requires: "token", + template: { + name: "github", + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${github_token}" }, + }, + inputs: [ + { key: "github_token", label: "GitHub token", placeholder: "ghp_…", secret: true, required: true }, + ], + }, + ], + secrets: [{ key: "BRAVE_API_KEY", label: "Brave API key", placeholder: "brv_…", secret: true, required: false }], + }, + }, +}; + export const WORKFLOWS = [ { name: "research-and-brief", diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 9bb4db5c2..2b7c3dab1 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -62,6 +62,46 @@ test("New agent → archetype picker → create returns to the list", async ({ p await expect(page.locator(".fleet-row", { hasText: "newbot" })).toBeVisible(); }); +test("New agent → configure a bundle's MCP inputs → create seeds them (#2041)", async ({ page }) => { + await openAgents(page); + + // Capture the create payload — the Configure step must carry the operator's inputs. + let posted = null; + await page.route("**/api/fleet", async (route) => { + if (route.request().method() === "POST") posted = route.request().postDataJSON(); + return route.continue(); + }); + + await page.getByRole("button", { name: "New agent" }).click(); + await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click(); + + // The picked bundle asks for a GitHub token (secret, masked) + declares a Brave secret; + // both surface in the inline Configure step (the preview peek supplies them). + const token = page.getByLabel("GitHub token"); + await expect(token).toBeVisible(); + await expect(page.getByLabel("Brave API key")).toBeVisible(); + await token.fill("ghp_secret"); + + await page.getByLabel("Agent name").fill("ghbot"); + await page.getByRole("button", { name: /Create/ }).click(); + + await expect(page.locator(".fleet-row", { hasText: "ghbot" })).toBeVisible(); + expect(posted?.inputs).toEqual({ github_token: "ghp_secret" }); + // The Brave secret was left blank → dropped (env-only fallback), not sent as an empty value. + expect(posted?.secrets ?? []).toEqual([]); +}); + +test("New agent preview dialog lists the bundle's MCP servers + secrets (#2041)", async ({ page }) => { + await openAgents(page); + await page.getByRole("button", { name: "New agent" }).click(); + await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click(); + await page.getByRole("button", { name: /See what.s included/ }).click(); + + const dialog = page.locator(".pl-dialog", { hasText: "What's included" }); + await expect(dialog.getByText("MCP servers: GitHub (needs token)")).toBeVisible(); + await expect(dialog.getByText("Secrets: Brave API key")).toBeVisible(); +}); + test("stop a running agent flips its status dot", async ({ page }) => { await openAgents(page); const ava = page.locator(".fleet-row", { hasText: "ava" }); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 10ca7c380..fe88285fb 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url"; import { ACTIVITY_HISTORY, ARCHETYPES, + ARCHETYPE_PREVIEWS, buildFrames, DELEGATES, DELEGATE_TYPES, @@ -586,6 +587,16 @@ const server = createServer(async (req, res) => { return sendJson(res, { enabled: true, goal, plan: goal ? GOAL_PLAN : "" }); } } + { + // Archetype bundle peek (#2041) — the enriched preview (mcp + secrets) the + // preview dialog and the new-agent Configure step both read. Unknown/code-free + // ids fall back to bundle:null. + const m = pathname.match(/^\/api\/archetypes\/([^/]+)\/preview$/); + if (m) { + const id = decodeURIComponent(m[1]); + return sendJson(res, ARCHETYPE_PREVIEWS[id] ?? { id, bundle: null }); + } + } const payload = handleApiGet(pathname, fleetFor(req)); if (payload !== null) return sendJson(res, payload); return sendJson(res, { detail: "not mocked" }, 404); diff --git a/apps/web/src/app/McpCatalogDialog.tsx b/apps/web/src/app/McpCatalogDialog.tsx index 5e0d51919..5666245ad 100644 --- a/apps/web/src/app/McpCatalogDialog.tsx +++ b/apps/web/src/app/McpCatalogDialog.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { api } from "../lib/api"; import { errMsg } from "../lib/format"; +import { fillTemplate } from "../lib/mcpTemplate"; import { runtimeStatusQuery } from "../lib/queries"; import type { McpCatalogEntry } from "../lib/types"; @@ -19,25 +20,6 @@ import type { McpCatalogEntry } from "../lib/types"; const MCP_CATALOG_KEY = ["mcp-catalog"] as const; -// Substitute ${key} placeholders in every string of the template (args, env, -// url, headers) with the operator-supplied values. -function fillTemplate( - template: Record, - values: Record, -): Record { - const sub = (v: unknown): unknown => { - if (typeof v === "string") return v.replace(/\$\{(\w+)\}/g, (_m, k: string) => values[k] ?? ""); - if (Array.isArray(v)) return v.map(sub); - if (v && typeof v === "object") { - return Object.fromEntries( - Object.entries(v as Record).map(([k, val]) => [k, sub(val)]), - ); - } - return v; - }; - return sub(template) as Record; -} - export function McpCatalogDialog({ open, onClose, diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 7ca943431..a4a436671 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -2060,6 +2060,35 @@ textarea { .archetype-name-field { max-width: 380px; } + +/* Inline Configure step (#2041) — the bundle's MCP inputs + declared secrets, collapsible. */ +.archetype-configure { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 380px; +} +.archetype-configure-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: none; + color: var(--fg); + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-align: left; +} +.archetype-configure-toggle .field-hint { + font-weight: 400; +} +.archetype-configure-fields { + display: flex; + flex-direction: column; + gap: 12px; +} .field-hint { font-size: 11px; color: var(--fg-muted); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0301caf3a..c758b1b58 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1409,6 +1409,11 @@ export const api = { port?: number; start?: boolean; shared_skills?: boolean; + // Operator-supplied bundle-seed values (#2041): `inputs` fill the bundle's MCP + // `${input}` placeholders (an entry seeds ENABLED when its required inputs are here), + // `secrets` carry values for the bundle's declared secrets. Omitted → env-only. + inputs?: Record; + secrets?: { key: string; value: string }[]; }) { return request<{ ok: boolean; agent: FleetAgent; installed: string[] }>("/api/fleet", { method: "POST", diff --git a/apps/web/src/lib/archetypeConfig.test.ts b/apps/web/src/lib/archetypeConfig.test.ts new file mode 100644 index 000000000..c1a03f965 --- /dev/null +++ b/apps/web/src/lib/archetypeConfig.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest"; + +import { + archetypeConfigFields, + configMissingRequired, + fieldId, + hasConfigFields, + mcpItemLabel, + previewMcpSummary, + previewSecretsSummary, + splitConfigValues, +} from "./archetypeConfig"; +import type { ArchetypePreview } from "./types"; + +// The new-agent Configure step (#2041 slice 3) and the enriched preview dialog both derive +// their behavior from these pure helpers, so they carry the real coverage: the form spec a +// bundle preview flattens to, the gate on required inputs, the split back into the two +// create() channels, and the read-only preview summaries. + +// A bundle preview with one MCP server (a plain root input + a secret token) and one +// declared standalone secret — the GitHub-style case from the acceptance criteria. +function githubPreview(): ArchetypePreview { + return { + id: "product-stack", + bundle: { + kind: "bundle", + name: "Product stack", + members: [], + mcp: [ + { + id: "github", + name: "GitHub", + requires: "token", + template: { env: { GITHUB_TOKEN: "${github_token}" }, args: ["--root", "${root}"] }, + inputs: [ + { key: "root", label: "Repo root", placeholder: "/work", required: true }, + { key: "github_token", label: "GitHub token", secret: true, required: true }, + ], + }, + ], + secrets: [{ key: "BRAVE_API_KEY", label: "Brave API key", secret: true }], + }, + }; +} + +describe("archetypeConfigFields — flatten a bundle preview into form fields", () => { + it("emits MCP inputs first, then declared secrets, tagged by origin", () => { + const fields = archetypeConfigFields(githubPreview()); + expect(fields.map((f) => [f.origin, f.key])).toEqual([ + ["input", "root"], + ["input", "github_token"], + ["secret", "BRAVE_API_KEY"], + ]); + }); + + it("masks secret MCP inputs and always masks declared secrets; plain inputs stay unmasked", () => { + const fields = archetypeConfigFields(githubPreview()); + expect(fields.find((f) => f.key === "root")?.secret).toBe(false); + expect(fields.find((f) => f.key === "github_token")?.secret).toBe(true); + expect(fields.find((f) => f.key === "BRAVE_API_KEY")?.secret).toBe(true); + }); + + it("returns no fields for a code-free archetype (bundle: null) — backward compat", () => { + const empty: ArchetypePreview = { id: "basic", bundle: null }; + expect(archetypeConfigFields(empty)).toEqual([]); + expect(hasConfigFields(empty)).toBe(false); + expect(archetypeConfigFields(undefined)).toEqual([]); + }); + + it("returns no fields for a bundle that declares neither mcp inputs nor secrets", () => { + const bare: ArchetypePreview = { id: "x", bundle: { kind: "bundle", members: [] } }; + expect(hasConfigFields(bare)).toBe(false); + }); +}); + +describe("configMissingRequired — required-input gate", () => { + const fields = archetypeConfigFields(githubPreview()); + + it("is true while any required field is blank or whitespace", () => { + expect(configMissingRequired(fields, {})).toBe(true); + expect( + configMissingRequired(fields, { [fieldId({ origin: "input", key: "root" })]: " " }), + ).toBe(true); + }); + + it("is false once every required field has a value", () => { + const values = { + [fieldId({ origin: "input", key: "root" })]: "/work", + [fieldId({ origin: "input", key: "github_token" })]: "ghp_1", + }; + // BRAVE_API_KEY is not required, so leaving it blank is fine. + expect(configMissingRequired(fields, values)).toBe(false); + }); +}); + +describe("splitConfigValues — collected form values back into create() channels", () => { + const fields = archetypeConfigFields(githubPreview()); + + it("routes inputs to the map and declared secrets to the list, dropping blanks", () => { + const values = { + [fieldId({ origin: "input", key: "root" })]: "/work", + [fieldId({ origin: "input", key: "github_token" })]: " ghp_1 ", + [fieldId({ origin: "secret", key: "BRAVE_API_KEY" })]: "", + }; + expect(splitConfigValues(fields, values)).toEqual({ + inputs: { root: "/work", github_token: "ghp_1" }, + secrets: [], + }); + }); + + it("carries a filled declared secret into the secrets list", () => { + const values = { [fieldId({ origin: "secret", key: "BRAVE_API_KEY" })]: "brv_9" }; + expect(splitConfigValues(fields, values)).toEqual({ + inputs: {}, + secrets: [{ key: "BRAVE_API_KEY", value: "brv_9" }], + }); + }); + + it("keeps an MCP input and a declared secret sharing a key from colliding", () => { + const preview: ArchetypePreview = { + id: "clash", + bundle: { + kind: "bundle", + members: [], + mcp: [{ id: "s", name: "S", template: {}, inputs: [{ key: "TOKEN", label: "Input token" }] }], + secrets: [{ key: "TOKEN", label: "Secret token", secret: true }], + }, + }; + const clashFields = archetypeConfigFields(preview); + const values = { + [fieldId({ origin: "input", key: "TOKEN" })]: "from-input", + [fieldId({ origin: "secret", key: "TOKEN" })]: "from-secret", + }; + expect(splitConfigValues(clashFields, values)).toEqual({ + inputs: { TOKEN: "from-input" }, + secrets: [{ key: "TOKEN", value: "from-secret" }], + }); + }); +}); + +describe("preview summaries — read-only display in ArchetypePreviewDialog", () => { + it("annotates each MCP server with what it needs", () => { + expect(mcpItemLabel({ id: "g", name: "GitHub", requires: "token", template: {} })).toBe( + "GitHub (needs token)", + ); + expect(mcpItemLabel({ id: "n", name: "Notion", template: {} })).toBe("Notion"); + expect( + previewMcpSummary([ + { id: "g", name: "GitHub", requires: "token", template: {} }, + { id: "b", name: "Brave Search", requires: "API key", template: {} }, + ]), + ).toBe("GitHub (needs token), Brave Search (needs API key)"); + }); + + it("lists secret labels", () => { + expect( + previewSecretsSummary([ + { key: "GH", label: "GitHub token" }, + { key: "BR", label: "Brave API key" }, + ]), + ).toBe("GitHub token, Brave API key"); + expect(previewSecretsSummary(undefined)).toBe(""); + }); +}); diff --git a/apps/web/src/lib/archetypeConfig.ts b/apps/web/src/lib/archetypeConfig.ts new file mode 100644 index 000000000..8d53d2dfe --- /dev/null +++ b/apps/web/src/lib/archetypeConfig.ts @@ -0,0 +1,109 @@ +import type { ArchetypePreview, McpCatalogEntry, McpCatalogInput } from "./types"; + +// The Configure step in NewAgentPanel (#2041 slice 3) and the enriched +// ArchetypePreviewDialog both read a bundle archetype's operator-facing setup off the +// preview peek: the MCP servers it will wire (each carrying its own `${input}` +// placeholders) and the standalone secrets it declares. These pure helpers turn that +// peek into (a) a flat form spec the panel renders + collects, and (b) the read-only +// summaries the preview dialog shows. No React — unit-tested directly. + +// One field in the inline Configure form. `origin` records which create() channel the +// value feeds: "input" → the `inputs` map (MCP `${key}` substitution at seed time), +// "secret" → the `secrets` list (the bundle's declared secrets). A declared secret is +// always masked; an MCP input is masked only when the catalog marks it `secret`. +export type ConfigField = { + key: string; + label: string; + placeholder?: string; + secret: boolean; + required: boolean; + origin: "input" | "secret"; + // The MCP server this input belongs to (context/grouping); undefined for a declared secret. + server?: string; +}; + +// Form state is keyed by origin+key so an MCP input and a declared secret that happen to +// share a `key` don't clobber each other in the value map. +export function fieldId(f: Pick): string { + return `${f.origin}:${f.key}`; +} + +// Flatten a bundle preview into the Configure form's fields: each MCP server's inputs +// first, then the bundle's declared secrets. Empty when the archetype has neither (no +// bundle, or a bundle with no inputs/secrets) → the panel shows no form (backward compat +// with input-free archetypes). +export function archetypeConfigFields(preview: ArchetypePreview | undefined): ConfigField[] { + const bundle = preview?.bundle; + if (!bundle) return []; + const fields: ConfigField[] = []; + for (const item of bundle.mcp ?? []) { + for (const inp of item.inputs ?? []) { + fields.push({ + key: inp.key, + label: inp.label, + placeholder: inp.placeholder, + secret: Boolean(inp.secret), + required: Boolean(inp.required), + origin: "input", + server: item.name, + }); + } + } + for (const sec of bundle.secrets ?? []) { + fields.push({ + key: sec.key, + label: sec.label, + placeholder: sec.placeholder, + secret: true, // a declared secret is always masked + required: Boolean(sec.required), + origin: "secret", + }); + } + return fields; +} + +// Anything to configure? Drives whether the inline form is offered at all. +export function hasConfigFields(preview: ArchetypePreview | undefined): boolean { + return archetypeConfigFields(preview).length > 0; +} + +// A required field left blank blocks create while the form is OPEN — the operator either +// fills it or collapses the form to skip (→ env-only). Trims so whitespace isn't "filled". +export function configMissingRequired(fields: ConfigField[], values: Record): boolean { + return fields.some((f) => f.required && !(values[fieldId(f)] ?? "").trim()); +} + +// Split the collected form values back into the two create() channels. Blank values are +// dropped so the backend's env/default fallthrough (#2041) still applies to whatever the +// operator skipped — only explicitly-entered values are sent. +export function splitConfigValues( + fields: ConfigField[], + values: Record, +): { inputs: Record; secrets: { key: string; value: string }[] } { + const inputs: Record = {}; + const secrets: { key: string; value: string }[] = []; + for (const f of fields) { + const v = (values[fieldId(f)] ?? "").trim(); + if (!v) continue; + if (f.origin === "secret") secrets.push({ key: f.key, value: v }); + else inputs[f.key] = v; + } + return { inputs, secrets }; +} + +// ── Read-only preview summaries (ArchetypePreviewDialog) ────────────────────────────── +// "GitHub (needs token)" — the server name, annotated with what it needs when the +// catalog entry declares a `requires`. +export function mcpItemLabel(item: McpCatalogEntry): string { + return item.requires ? `${item.name} (needs ${item.requires})` : item.name; +} + +// "GitHub (needs token), Brave Search (needs API key)" +export function previewMcpSummary(mcp: McpCatalogEntry[] | undefined): string { + return (mcp ?? []).map(mcpItemLabel).join(", "); +} + +// "GitHub token, Brave API key" +export function previewSecretsSummary(secrets: McpCatalogInput[] | undefined): string { + return (secrets ?? []).map((s) => s.label).join(", "); +} diff --git a/apps/web/src/lib/mcpTemplate.test.ts b/apps/web/src/lib/mcpTemplate.test.ts new file mode 100644 index 000000000..e06225c43 --- /dev/null +++ b/apps/web/src/lib/mcpTemplate.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; + +import { fillTemplate } from "./mcpTemplate"; + +// fillTemplate is the shared `${input}` substitution (#2041 slice 3), extracted from +// McpCatalogDialog so the MCP catalog quick-add and the new-agent Configure step share one +// implementation. These guard its recursive substitution across the template shapes MCP +// entries actually use (args arrays, nested env/headers maps). + +describe("fillTemplate — ${key} substitution across a template", () => { + it("substitutes placeholders in top-level strings", () => { + expect(fillTemplate({ url: "https://api/${token}" }, { token: "abc" })).toEqual({ + url: "https://api/abc", + }); + }); + + it("recurses into arrays (an args list) and nested objects (env/headers)", () => { + const template = { + command: "npx", + args: ["-y", "server", "--dir", "${root}"], + env: { GITHUB_TOKEN: "${token}" }, + headers: { Authorization: "Bearer ${token}" }, + }; + expect(fillTemplate(template, { root: "/work", token: "ghp_1" })).toEqual({ + command: "npx", + args: ["-y", "server", "--dir", "/work"], + env: { GITHUB_TOKEN: "ghp_1" }, + headers: { Authorization: "Bearer ghp_1" }, + }); + }); + + it("replaces an unfilled placeholder with an empty string (never leaves ${...})", () => { + expect(fillTemplate({ env: { KEY: "${missing}" } }, {})).toEqual({ env: { KEY: "" } }); + }); + + it("leaves non-string leaves (numbers, booleans, null) untouched", () => { + const template = { port: 8080, tls: true, extra: null, name: "srv-${id}" }; + expect(fillTemplate(template, { id: "x" })).toEqual({ + port: 8080, + tls: true, + extra: null, + name: "srv-x", + }); + }); +}); diff --git a/apps/web/src/lib/mcpTemplate.ts b/apps/web/src/lib/mcpTemplate.ts new file mode 100644 index 000000000..1ebb8b5ed --- /dev/null +++ b/apps/web/src/lib/mcpTemplate.ts @@ -0,0 +1,20 @@ +// Substitute ${key} placeholders in every string of an MCP server template (args, env, +// url, headers) with the operator-supplied values. Shared utility (#2041 slice 3): the +// MCP catalog quick-add (Settings ▸ MCP) and the new-agent Configure step both fill the +// same catalog-shaped templates, so the substitution lives here rather than in either UI. +export function fillTemplate( + template: Record, + values: Record, +): Record { + const sub = (v: unknown): unknown => { + if (typeof v === "string") return v.replace(/\$\{(\w+)\}/g, (_m, k: string) => values[k] ?? ""); + if (Array.isArray(v)) return v.map(sub); + if (v && typeof v === "object") { + return Object.fromEntries( + Object.entries(v as Record).map(([k, val]) => [k, sub(val)]), + ); + } + return v; + }; + return sub(template) as Record; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index b7950cda6..25f5e3fec 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -963,6 +963,12 @@ export type ArchetypePreview = { verified_against?: string; enabled?: string[]; members: ArchetypePreviewMember[]; + // What the bundle will ask the operator to fill (#2041): catalog-shaped MCP servers + // (each with `${input}` placeholders + their `inputs` spec) and the standalone secrets + // it declares. Surfaced up front so the preview can show them and the new-agent + // Configure step can collect them WITHOUT installing (read-only peek). + mcp?: McpCatalogEntry[]; + secrets?: McpCatalogInput[]; } | null; }; diff --git a/apps/web/src/settings/NewAgentPanel.tsx b/apps/web/src/settings/NewAgentPanel.tsx index ff08c1768..f8bc25ed5 100644 --- a/apps/web/src/settings/NewAgentPanel.tsx +++ b/apps/web/src/settings/NewAgentPanel.tsx @@ -1,8 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft } from "lucide-react"; -import { useState } from "react"; +import { ArrowLeft, ChevronDown, ChevronRight } from "lucide-react"; +import { useMemo, useState } from "react"; -import { Input, RadioCard, RadioCardGroup } from "@protolabsai/ui/forms"; +import { Input, RadioCard, RadioCardGroup, SecretInput } from "@protolabsai/ui/forms"; import { Button } from "@protolabsai/ui/primitives"; import { PanelHeader } from "@protolabsai/ui/navigation"; import { useToast } from "@protolabsai/ui/overlays"; @@ -11,13 +11,15 @@ import { api } from "../lib/api"; import { ArchetypePreviewDialog } from "../setup/ArchetypePreviewDialog"; import { archetypesQuery, queryKeys } from "../lib/queries"; import { lucideIcon } from "../lib/lucideIcon"; +import { archetypeConfigFields, configMissingRequired, fieldId, splitConfigValues } from "../lib/archetypeConfig"; import type { Archetype } from "../lib/types"; const NAME_RE = /^[A-Za-z0-9-_]+$/; // Onboarding / archetype picker (ADR 0042). Pick an archetype (Basic + installed bundles), -// name the agent, create. Creating from a bundle clones+installs it (a few seconds) — the -// POST returns once the agent is up, so the button shows a spinner until then. +// optionally configure the bundle's MCP inputs + secrets (#2041), name the agent, create. +// Creating from a bundle clones+installs it (a few seconds) — the POST returns once the +// agent is up, so the button shows a spinner until then. export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => void; onCancel?: () => void }) { const qc = useQueryClient(); const toast = useToast(); @@ -25,20 +27,56 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => const [picked, setPicked] = useState("basic"); const [name, setName] = useState(""); const [previewOpen, setPreviewOpen] = useState(false); + // The inline Configure step: expanded by default when the archetype has inputs, so the + // operator sees what to fill; collapsing skips it (→ env-only seed). `values` is keyed by + // fieldId(origin+key) so an MCP input and a declared secret sharing a key don't collide. + const [configOpen, setConfigOpen] = useState(true); + const [values, setValues] = useState>({}); // "custom" is a wizard-only persona (write-your-own SOUL) — this picker creates an // agent from a bundle and has no SOUL editor, so Custom would just duplicate Basic. const list = (archetypes.data?.archetypes ?? []).filter((a) => a.id !== "custom"); const pickedArchetype = list.find((a) => a.id === picked); - const archetype = list.find((a) => a.id === picked) ?? list[0]; + const archetype = pickedArchetype ?? list[0]; const nameOk = NAME_RE.test(name); + // The picked archetype's read-only peek — the source of the Configure form's fields (its + // bundle's MCP inputs + declared secrets). Shares the dialog's cache key; only fetched for + // bundle-backed archetypes (Basic has no bundle → no form, backward compatible). + const preview = useQuery({ + queryKey: ["archetype-preview", picked], + queryFn: () => api.archetypePreview(picked), + enabled: Boolean(pickedArchetype?.bundle), + staleTime: 10 * 60 * 1000, + retry: 1, + }); + const fields = useMemo(() => archetypeConfigFields(preview.data), [preview.data]); + // A required field left blank is a soft hint, NOT a hard gate — skipping the Configure step + // (or an individual required field) is a first-class path that falls back to env-only. + const missingRequired = configOpen && configMissingRequired(fields, values); + + function pick(id: string) { + setPicked(id); + setValues({}); // a token typed for one archetype must not carry into the next + setConfigOpen(true); + } + const create = useMutation({ // Carry the archetype's base SOUL so a bundle agent arrives WITH its persona, not just // its tools (ADR 0042). Blank soul (bundle with no inline persona) → server leaves the - // agent on the default SOUL. - mutationFn: () => - api.createAgent({ name: name.trim(), bundle: archetype?.bundle ?? null, soul: archetype?.soul || undefined }), + // agent on the default SOUL. When the Configure form is open, split the collected values + // into the two seed channels (#2041); a collapsed/absent form sends nothing → env-only. + mutationFn: () => { + const { inputs, secrets } = + configOpen && fields.length ? splitConfigValues(fields, values) : { inputs: {}, secrets: [] }; + return api.createAgent({ + name: name.trim(), + bundle: archetype?.bundle ?? null, + soul: archetype?.soul || undefined, + inputs: Object.keys(inputs).length ? inputs : undefined, + secrets: secrets.length ? secrets : undefined, + }); + }, onError: (e: Error) => toast({ tone: "error", title: "Couldn't create agent", message: e.message }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: queryKeys.fleet }); @@ -63,7 +101,7 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => />

Archetype

- + {list.map((a: Archetype) => ( ))} @@ -77,6 +115,56 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => setPreviewOpen(false)} /> ) : null} + {/* Inline Configure step (#2041) — appears only when the picked bundle has MCP inputs + or declared secrets. Collapsible: skipping falls back to this host's environment. */} + {fields.length ? ( +
+ + {configOpen ? ( +
+ {fields.map((f) => ( + + ))} + {missingRequired ? ( + + Fields marked * connect their server — fill them, or skip to use this host's environment. + + ) : null} +
+ ) : null} +
+ ) : null} +
+ {/* What the bundle asks the operator to supply (#2041) — pure display; the + new-agent Configure step collects these. */} + {preview.data.bundle.mcp?.length ? ( +

+ MCP servers: {previewMcpSummary(preview.data.bundle.mcp)} +

+ ) : null} + {preview.data.bundle.secrets?.length ? ( +

+ Secrets: {previewSecretsSummary(preview.data.bundle.secrets)} +

+ ) : null} ) : null} From 9c622219dc444e9448f1a6aa40b1c0edaa823fa9 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 21 Jul 2026 23:51:24 -0700 Subject: [PATCH 2/2] =?UTF-8?q?style:=20rename=20configMissingRequired=20?= =?UTF-8?q?=E2=86=92=20isMissingRequiredConfig=20=E2=80=94=20predicate=20p?= =?UTF-8?q?refix=20parity=20with=20hasConfigFields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA panel finding on #2127 (confirmed, adjudicated fix-before-merge): adjacent boolean predicates in the new archetypeConfig module should share the is/has prefix convention. Zero-reference rename (both symbols new in this PR). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hh8gD6749kPXoBK9Wsd1FA --- apps/web/src/lib/archetypeConfig.test.ts | 10 +++++----- apps/web/src/lib/archetypeConfig.ts | 2 +- apps/web/src/settings/NewAgentPanel.tsx | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/archetypeConfig.test.ts b/apps/web/src/lib/archetypeConfig.test.ts index c1a03f965..6b0f36a0c 100644 --- a/apps/web/src/lib/archetypeConfig.test.ts +++ b/apps/web/src/lib/archetypeConfig.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { archetypeConfigFields, - configMissingRequired, + isMissingRequiredConfig, fieldId, hasConfigFields, mcpItemLabel, @@ -73,13 +73,13 @@ describe("archetypeConfigFields — flatten a bundle preview into form fields", }); }); -describe("configMissingRequired — required-input gate", () => { +describe("isMissingRequiredConfig — required-input gate", () => { const fields = archetypeConfigFields(githubPreview()); it("is true while any required field is blank or whitespace", () => { - expect(configMissingRequired(fields, {})).toBe(true); + expect(isMissingRequiredConfig(fields, {})).toBe(true); expect( - configMissingRequired(fields, { [fieldId({ origin: "input", key: "root" })]: " " }), + isMissingRequiredConfig(fields, { [fieldId({ origin: "input", key: "root" })]: " " }), ).toBe(true); }); @@ -89,7 +89,7 @@ describe("configMissingRequired — required-input gate", () => { [fieldId({ origin: "input", key: "github_token" })]: "ghp_1", }; // BRAVE_API_KEY is not required, so leaving it blank is fine. - expect(configMissingRequired(fields, values)).toBe(false); + expect(isMissingRequiredConfig(fields, values)).toBe(false); }); }); diff --git a/apps/web/src/lib/archetypeConfig.ts b/apps/web/src/lib/archetypeConfig.ts index 8d53d2dfe..a08614bfa 100644 --- a/apps/web/src/lib/archetypeConfig.ts +++ b/apps/web/src/lib/archetypeConfig.ts @@ -69,7 +69,7 @@ export function hasConfigFields(preview: ArchetypePreview | undefined): boolean // A required field left blank blocks create while the form is OPEN — the operator either // fills it or collapses the form to skip (→ env-only). Trims so whitespace isn't "filled". -export function configMissingRequired(fields: ConfigField[], values: Record): boolean { +export function isMissingRequiredConfig(fields: ConfigField[], values: Record): boolean { return fields.some((f) => f.required && !(values[fieldId(f)] ?? "").trim()); } diff --git a/apps/web/src/settings/NewAgentPanel.tsx b/apps/web/src/settings/NewAgentPanel.tsx index f8bc25ed5..90c2b5e35 100644 --- a/apps/web/src/settings/NewAgentPanel.tsx +++ b/apps/web/src/settings/NewAgentPanel.tsx @@ -11,7 +11,7 @@ import { api } from "../lib/api"; import { ArchetypePreviewDialog } from "../setup/ArchetypePreviewDialog"; import { archetypesQuery, queryKeys } from "../lib/queries"; import { lucideIcon } from "../lib/lucideIcon"; -import { archetypeConfigFields, configMissingRequired, fieldId, splitConfigValues } from "../lib/archetypeConfig"; +import { archetypeConfigFields, isMissingRequiredConfig, fieldId, splitConfigValues } from "../lib/archetypeConfig"; import type { Archetype } from "../lib/types"; const NAME_RE = /^[A-Za-z0-9-_]+$/; @@ -53,7 +53,7 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => const fields = useMemo(() => archetypeConfigFields(preview.data), [preview.data]); // A required field left blank is a soft hint, NOT a hard gate — skipping the Configure step // (or an individual required field) is a first-class path that falls back to env-only. - const missingRequired = configOpen && configMissingRequired(fields, values); + const missingRequired = configOpen && isMissingRequiredConfig(fields, values); function pick(id: string) { setPicked(id);