diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index 90d3753767..1b1090de77 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -2,7 +2,9 @@ ## Current implementation (2026-09-07) -The shipped new-agent flow starts from **Agents → New Agent**. A small dialog collects a name and an enabled adapter, then opens a setup page with numbered navigation. Claude and Codex have a subscription/API-key connection step. Configuration provides a searchable, free-text model selector and an environment selector; additional settings remain on the full agent page. **Finish setup** submits the existing governed hire request. Confirmation links to configuration and opens a new task with the created agent assigned; agents awaiting approval cannot be assigned work yet. +The shipped new-agent flow starts from **Agents → New Agent**. A small dialog collects a name and an enabled adapter, then opens a setup page with numbered navigation. Claude, Codex, and Grok have a subscription/API-key connection step. Configuration provides a searchable, free-text model selector and an environment selector; additional settings remain on the full agent page. **Finish setup** submits the existing governed hire request. Confirmation links to configuration and opens a new task with the created agent assigned; agents awaiting approval cannot be assigned work yet. + +On Cloud, the new-agent picker and direct setup links allow Claude, Codex, OpenCode, and Grok. Four available adapters use a two-column grid on desktop and mobile. Grok uses the existing `grok_local` adapter with an xAI subscription or API-key connection and the managed sandbox environment. The picker and connection step share provider marks that adapt to light and dark mode. An adapter must also be loaded and enabled to be available. Paperclip Runner offers native Codex (app server), Claude via ACPX, and OpenCode. The selected provider is passed through the existing runner configuration builder. Codex's default is the adapter catalog default. OpenCode and Pi require a provider/model ID; OpenRouter uses `openrouter//` and `OPENROUTER_API_KEY`. Entered keys are tested through the probe-only `testCredentials` field without storage. Finishing setup saves each key as an isolated user secret so a failed test cannot overwrite another agent’s credential, and an existing organization secret can also be bound. Agent configuration and revisions contain references, never the entered key. diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 8ec88550b4..3259e030f0 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -132,7 +132,7 @@ it.each([false, undefined])( }, ); -it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabled", async () => { +it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enabled", async () => { await act(async () => { cache.setQueryData(queryKeys.health, { status: "ok", @@ -160,8 +160,15 @@ it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabl [...document.querySelectorAll('input[type="radio"]')].map( (input) => input.value, ), - ).toEqual(["claude_local", "codex_local", "opencode_local"]); + ).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local"]); expect(document.body.textContent).not.toContain("CLI harness"); + await act(async () => + document.querySelector('input[value="grok_local"]')!.click(), + ); + await click("Configure agent"); + const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams; + expect(query.get("adapterType")).toBe("grok_local"); + expect(query.get("name")).toBe("Ada & Co"); }); it("keeps agent-only invitations reachable from the new-agent flow", async () => { diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index de8a3f7a84..173e1cd248 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -226,7 +226,7 @@ export function AgentBasicsDialog({ {error.message}

)} -
+
{choices.map((adapter) => { const display = getAdapterDisplay(adapter.type); return ( diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index d51b1c0422..137adf3950 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -12,6 +12,7 @@ import { import { agentsApi } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; import { AdapterLoginPanel } from "../AgentConfigForm"; +import { AdapterMark } from "./AgentBasicsDialog"; import { LocalProviderLoginInstructions, OnboardingCardField, @@ -222,13 +223,7 @@ export function AgentProviderConnection({ { id: adapterType, label: provider, - icon: ( - - ), + icon: , }, ]} mode={method} diff --git a/ui/src/lib/new-agent-adapters.ts b/ui/src/lib/new-agent-adapters.ts index a6e8e4d00f..6ca31f52d2 100644 --- a/ui/src/lib/new-agent-adapters.ts +++ b/ui/src/lib/new-agent-adapters.ts @@ -2,6 +2,7 @@ const CLOUD_ADAPTERS = new Set([ "claude_local", "codex_local", "opencode_local", + "grok_local", ]); /** Creation policy shared by the picker and direct setup links. */ diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index d71f1ef6a7..c7a50167c8 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -80,8 +80,8 @@ vi.mock("@/components/AgentConfigForm", () => ({ onChange={(e) => onChange(e.target.value)} /> ), - AdapterLoginPanel: ({ onStored }: { onStored: (id: string) => void }) => ( - ), @@ -213,6 +213,56 @@ describe("New agent setup", () => { expect(container.textContent).toContain("This adapter is unavailable"); expect(api.hire).not.toHaveBeenCalled(); }); + it.each(["subscription", "api_key"])("configures Grok on Cloud with an xAI %s connection", async (method) => { + cache.setQueryData(queryKeys.health, { + status: "ok", + cloud: { managed: true }, + }); + envApi.list.mockResolvedValue([ + { id: "sandbox-1", name: "Paperclip Cloud", driver: "sandbox", config: { provider: "daytona" } }, + ]); + envApi.capabilities.mockResolvedValue({ + sandboxProviders: { daytona: { supportsLoginPty: true } }, + }); + settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); + settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true }); + api.getAdapterAuthSignal.mockResolvedValue({ status: "missing" }); + api.testEnvironment.mockResolvedValue({ ...pass, adapterType: "grok_local" }); + await render("grok_local"); + expect(container.textContent).toContain("Connect Atlas to Grok"); + if (method === "subscription") { + await click("GrokSubscription"); + await click("Complete subscription login"); + } else { + await click("Use API key insteadUse subscription insteadUse API key instead"); + await click("GrokAPI"); + await fill("API key", "example-test-secret"); + await click("Connect"); + expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + provider: "xai", method: "api_key", apiKey: "example-test-secret", + })); + } + await fill("Model", "grok-code-fast-1"); + await click("Run test"); + const binding = { provider: "xai", method, mode: "responsible_user" }; + expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "grok_local", expect.objectContaining({ + environmentId: "sandbox-1", + adapterConfig: expect.objectContaining({ model: "grok-code-fast-1" }), + aiConnection: binding, + testCredentials: {}, + })); + await click("Finish setup"); + expect(api.hire).toHaveBeenCalledTimes(1); + expect(api.hire.mock.calls[0][1]).toMatchObject({ + adapterType: "grok_local", + defaultEnvironmentId: "sandbox-1", + adapterConfig: { model: "grok-code-fast-1" }, + runtimeConfig: { aiConnection: binding, heartbeat: { enabled: false } }, + }); + expect(JSON.stringify(api.testEnvironment.mock.calls)).not.toContain("example-test-secret"); + expect(JSON.stringify(api.hire.mock.calls)).not.toContain("example-test-secret"); + expect(container.textContent).toContain("Your agent is ready"); + }); it("sends Cursor Cloud repo/ref and transient API key, then saves an organization secret", async () => { await render("cursor_cloud"); expect(container.querySelector('[aria-label="Model"]')).toBeNull();