feat: simplify agent onboarding and configuration (#13011)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators create agents and configure their runtimes in the board UI. > - The old creation flow presents several choices and a large form before an agent can start. > - The existing onboarding controls already provide clear provider connection steps. > - This pull request uses those controls in a new-agent wizard and organizes the full configuration pages. > - Operators can connect, test, save, and assign a first task while keeping the existing configuration tools. ## Linked Issues or Issue Description Related: #10974. That earlier open PR also reorganizes agent configuration. This PR follows the reviewed Storybook designs for agent creation and the current configuration tabs. **What existing behavior does this improve?** Agent creation, provider connection, runtime tests, and full agent configuration. **Current behavior** The creation dialog leads to a large manual configuration form. Provider login controls differ from onboarding. Environment variables and secret access appear in separate places. **Proposed behavior** Choose a name and adapter. Connect Claude or Codex through the existing onboarding controls. Configure and test the runtime, save the agent, and open a task dialog with that agent assigned. Use the same design on the existing configuration tabs. **Reason and benefit** The first setup asks for fewer decisions. The full editor keeps instructions, skills, runtime controls, secret access, permissions, keys, and revisions available in clear sections. **Breaking changes** The board creation and configuration layouts change. The test-environment API adds an optional, allowlisted `testCredentials` field for one-shot probes. Database contracts stay the same. Native ACPX tests now reject unsupported local platforms before a CLI login can mask the runtime restriction. ## What Changed - Added a new-agent wizard with numbered steps, adapter branding, provider connections, editable model choices, runtime tests, and confirmation. - Added Codex app-server, Claude ACPX, and OpenCode runner choices. - Stored API credentials through existing secret APIs and persisted references in agent configuration. New setup keys are isolated from credentials used by existing agents. - Preserved external-agent invitations beside the wizard, including optional messages, one-time prompts, and clipboard fallback. - Added OpenRouter provider and secret bindings for Pi and OpenCode. - Added adapter-specific prerequisite fields for Cursor, Gemini, Kimi, and Hermes. Cursor Cloud keys are saved as new organization secrets. - Fixed Cursor Cloud repository field mapping, omitted empty remote environment values, and added useful model and repository error messages. - Preserved complete MCP assignments when multiple valid profiles contain more than 250 tools in total. Generated profiles retain exact tool selectors. - Added service branding and deployment-aware adapter choices. Cloud setup offers Claude, Codex, and OpenCode; local native runners require the experimental setting. - Made the agent list responsive at intermediate widths. - Applied the reviewed design to the real agent configuration pages. Kept the instruction editor, skills, and existing mutations. - Combined secret access and environment variables under one Save and Discard action. - Added interactive Storybook screens for setup, configuration, confirmation, authentication, and test results. - Fixed Pi provider-error parsing and thinking-effort persistence. Native ACPX validates Linux x64 on the actual local, SSH, or sandbox target. - Redacted the complete transient probe-credential field from HTTP error logs, including rejected provider names. ## Verification - Current head `df0292fe6` has a fresh Greptile 5/5 review with no unresolved findings. All 31 executed CI checks passed, including the aggregate verification gate and all browser E2E shards. Storybook visual regression is skipped by its workflow; the local Storybook build passed. - Browser tests completed real assigned tasks with direct Codex, Claude, OpenCode, Pi, and native Codex. - Verified external-agent invitation generation and automatic prompt copying in the live browser. - Pi and OpenCode used an existing OpenRouter secret. Browser checks covered save and reload, instruction edits, skill selection, environment-variable Save and Discard, and assigned task creation. - Invalid Claude API credentials remained on the connection step with an error. A live Pi/OpenRouter invalid-key probe returned a provider failure and left the user-secret inventory unchanged (zero entries before and after). - Full workspace typecheck and build passed after rebasing onto current master. After review fixes, server and UI typechecks, token gates, and the full build passed again. Storybook built successfully. - All 5,542 local UI tests passed. The Cursor Cloud and Pi adapter regressions passed all 24 tests. Review regressions passed 69 server tests and all 18 agent-list tests. - The local full test command ran 6,971 general server tests successfully. Editing review fixes during that long run caused nine tests to use stale modules; fresh isolated runs passed. An unrelated embedded-Postgres fixture hit the host shared-memory limit; its 15 affected tests passed when the fixture groups ran separately. - Local workspace groups passed after rerunning 18 CLI tests sequentially to avoid host database limits and parallel-load timeouts. The local full command stopped at the general server phase, so serialized server verification comes from the five passing CI shards. - Browser testing at 390px confirmed that the agent action menu opens and the page has no horizontal overflow. CI browser E2E shards passed. - Review the `Onboarding / New agent` and `Agents / Configuration refresh` Storybook groups. In the real app, create an agent, run its connection test, save it, assign a task, and reload its configuration. ## Risks - This changes the main agent setup and configuration UI. Regression tests cover routing, persistence, secret bindings, and form actions. - Native Claude ACPX requires Linux x64. Direct Claude works on macOS. Remote checks execute a bounded platform probe and reject unsupported or unverified targets. - A native OpenCode task reached the provider context limit because of its tool payload. Its provider connection test passed. Direct OpenCode completed a task. This existing native execution limit is not fixed here. - Claude and Codex connection keys use the existing user-secret store. Other runtime setup keys use distinct organization secrets. Existing credentials are never rotated. Probes do not store entered keys. Failed agent creation removes newly staged credentials. - Cursor Cloud has not completed a live task. Its authenticated account still needs GitHub repository access. The live run passed MCP provisioning, remote environment validation, and explicit Auto model selection before the repository prerequisite blocked execution. - Generated runtime MCP profiles can exceed the public profile-edit request limit. They still contain exact catalog selectors and preserve permission boundaries. - No database migration, dependency, lockfile, or workflow changes are included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell execution, and browser automation. The runtime did not expose the exact model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
|
|
@ -0,0 +1,33 @@
|
|||
# New-agent setup prerequisites
|
||||
|
||||
Reviewed against the adapter builders, runtime probes, and provider documentation on 2026-09-08.
|
||||
|
||||
| Adapter | Setup connection/prerequisites | Model and effort in setup |
|
||||
| --- | --- | --- |
|
||||
| Claude Code / Codex | Existing subscription/API connection step | Searchable model; supported effort options |
|
||||
| Paperclip Runner | The selected Codex, Claude ACPX, or OpenCode connection | Provider model; no generic effort setting |
|
||||
| Cursor CLI | `CURSOR_API_KEY`, existing organization secret, or host `agent login` | Model; no generic effort (Cursor uses modes) |
|
||||
| Cursor Cloud | Enter a new `CURSOR_API_KEY` and repository URL; optional starting branch/ref. The key is saved as a new organization secret; setup does not reuse existing keys. | Account-default model; no generic effort |
|
||||
| Gemini CLI | `GEMINI_API_KEY`, existing organization secret, or supported host login | Model; no effort control |
|
||||
| Kimi Code | Host login, or `KIMI_MODEL_API_KEY` plus `KIMI_MODEL_NAME`; optional protocol/base URL | Existing model alias for host login; API model name for API auth; no effort on default ACP lane |
|
||||
| Grok Build | Host `grok login` | Model and reasoning effort |
|
||||
| Hermes | Provider API key or existing host configuration | Model; keep optional CLI tuning in full configuration |
|
||||
| Hermes Gateway | API base URL and `API_SERVER_KEY` | Model is controlled by the gateway; no effort control |
|
||||
| OpenCode / Pi | Provider/model ID and optional provider key/organization secret | Model; Pi thinking levels; OpenCode-specific variants remain in full configuration |
|
||||
|
||||
## Contracts and regression coverage
|
||||
|
||||
- Cursor Cloud sends `repoUrl` and `repoStartingRef`, matching its SDK adapter. It never sends the old, ignored `repository`/`branch` properties.
|
||||
- New runtime keys are stored as distinct organization secrets when setup completes. They do not rotate existing keys. Existing Claude/Codex connection flows retain their user-specific credential behavior.
|
||||
- A draft key goes only into the allowlisted `testCredentials` request field during testing. It does not pass through persistence normalization. Hermes Gateway's one-shot key maps to its top-level `apiKey` only after normalization.
|
||||
- Failed tests and abandoned forms do not create secrets. A failed hire removes the newly created secret; cleanup errors remain visible.
|
||||
- Kimi API mode omits `config.model`: passing `--model` would override the model synthesized from `KIMI_MODEL_*` variables.
|
||||
- `ui/src/pages/NewAgent.test.tsx` covers field visibility, request payloads, secret reuse/storage, failure cleanup, and Kimi/Hermes mappings. `server/src/__tests__/agent-test-environment-routes.test.ts` verifies transient credential handling and rejects arbitrary environment variables.
|
||||
|
||||
UI and mocked contract tests do not prove a provider account can run a task. Successful live authentication requires the user's credential and, for Cursor Cloud, a repository connected to that Cursor account. The live invalid-key browser check verifies that a populated repository no longer produces the missing-repository diagnostic.
|
||||
|
||||
## Provider references
|
||||
|
||||
- [Cursor Cloud API](https://prod.cursor.com/docs/cloud-agent/api/endpoints) and [source-control prerequisites](https://prod.cursor.com/docs/cloud-agent).
|
||||
- [Kimi environment-defined models](https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/env-vars.html).
|
||||
- [Grok CLI sign-in](https://docs.x.ai/build/cli/reference).
|
||||
|
|
@ -1,5 +1,19 @@
|
|||
# Agent Configuration & Activity UI
|
||||
|
||||
## 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.
|
||||
|
||||
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/<provider>/<model>` 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.
|
||||
|
||||
**Run test** uses the chosen environment and current configuration. For Claude/Codex readiness checks that do not make a model request, setup also invokes the adapter's existing CLI hello probe with the same environment and credentials. Runtime failures, provider failures, warnings, and in-progress tests use the same compact result card in setup and full configuration. Warnings remain distinguishable from blocking failures.
|
||||
|
||||
The new-agent dialog also retains an external-agent invitation link, with an optional message, one-time onboarding prompt, and clipboard fallback. External agents still require organization-admin approval.
|
||||
|
||||
The full configuration page retains the contextual navigation and existing instruction-file editor, skills, tools, permissions, API keys, and revision behavior. **Secrets & variables** groups environment bindings with API secret-access grants. Its page-level Save/Discard actions include editor-local environment drafts. Activity, runs, costs, and budgets link to the company audit views scoped to the agent.
|
||||
|
||||
The sections below are the original design reference; the implementation summary above supersedes their creation-dialog layout.
|
||||
|
||||
## Context
|
||||
|
||||
Agents are the employees of a Paperclip company. Each agent has an adapter type (`claude_local`, `codex_local`, `process`, `http`) that determines how it runs, a position in the org chart (who it reports to), a heartbeat policy (how/when it wakes up), and a budget. The UI at `/agents` needs to support creating and configuring agents, viewing their org hierarchy, and inspecting what they've been doing -- their run history, live logs, and accumulated costs.
|
||||
|
|
@ -284,3 +298,8 @@ All endpoints already exist. No new server work needed for V1.
|
|||
9. **Properties panel updates** -- session ID, last error
|
||||
|
||||
Steps 1-5 are the core. Steps 6-9 are polish.
|
||||
|
||||
Native ACPX connection tests reject unsupported local platforms before a host CLI
|
||||
login can incorrectly mark the runner connected. The existing verified Claude
|
||||
ACPX runtime requires Linux x64; remote environments are evaluated independently
|
||||
of the control-plane host platform.
|
||||
|
|
|
|||
|
|
@ -207,6 +207,33 @@ describe("cursor_cloud execute", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("omits empty environment values while preserving nonempty values exactly", async () => {
|
||||
createMock.mockResolvedValue(createMockSdkAgent());
|
||||
const ctx = createContext();
|
||||
ctx.config.env = {
|
||||
CURSOR_API_KEY: "cursor-secret",
|
||||
GH_TOKEN: "",
|
||||
GITHUB_TOKEN: "",
|
||||
GIT_AUTHOR_NAME: "",
|
||||
SSH_AUTH_SOCK: "",
|
||||
EMPTY_PLAIN: { type: "plain", value: "" },
|
||||
EXTRA_FLAG: "0",
|
||||
PADDED_VALUE: " retain whitespace ",
|
||||
};
|
||||
|
||||
await execute(ctx);
|
||||
|
||||
const env = createMock.mock.calls[0]?.[0]?.cloud?.envVars;
|
||||
expect(env).not.toHaveProperty("GH_TOKEN");
|
||||
expect(env).not.toHaveProperty("GITHUB_TOKEN");
|
||||
expect(env).not.toHaveProperty("GIT_AUTHOR_NAME");
|
||||
expect(env).not.toHaveProperty("SSH_AUTH_SOCK");
|
||||
expect(env).not.toHaveProperty("EMPTY_PLAIN");
|
||||
expect(env).not.toHaveProperty("CURSOR_API_KEY");
|
||||
expect(env).toMatchObject({ EXTRA_FLAG: "0", PADDED_VALUE: " retain whitespace " });
|
||||
expect(Object.values(env).every((value) => value !== "")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dispatch before starting the first remote SDK operation", async () => {
|
||||
const run = createMockRun({ agentId: "agent-dispatch" });
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-dispatch", sendRun: run });
|
||||
|
|
@ -354,6 +381,36 @@ describe("cursor_cloud execute", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("explains a rejected Cursor default without silently choosing another model", async () => {
|
||||
const sdkAgent = createMockSdkAgent();
|
||||
sdkAgent.send.mockRejectedValue(new Error("[invalid_model] Model 'gpt-5' is not available or invalid."));
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
delete ctx.config.model;
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(createMock.mock.calls[0]?.[0]).not.toHaveProperty("model");
|
||||
expect(sdkAgent.send).toHaveBeenCalledWith(expect.any(String), {});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorMessage).toContain("Cursor rejected its configured default model");
|
||||
expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents");
|
||||
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("makes Cursor repository setup failures actionable without launching another run", async () => {
|
||||
const sdkAgent = createMockSdkAgent();
|
||||
sdkAgent.send.mockRejectedValue(new Error("[validation_error] Failed to determine repository default branch"));
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
|
||||
const result = await execute(createContext());
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorMessage).toContain("Cursor's GitHub integration can access https://github.com/paperclipai/paperclip.git");
|
||||
expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents");
|
||||
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps non-finished Cursor results to failing Paperclip runs", async () => {
|
||||
const cancelledRun = createMockRun({
|
||||
id: "run-cancelled",
|
||||
|
|
|
|||
|
|
@ -168,7 +168,10 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
|||
}
|
||||
|
||||
delete env.CURSOR_API_KEY;
|
||||
return env;
|
||||
// Cursor rejects the entire request when any envVars value is empty.
|
||||
// Paperclip may use empty values to unset optional host credentials; remote
|
||||
// workers do not inherit those host variables, so omit the empty entries.
|
||||
return Object.fromEntries(Object.entries(env).filter(([, value]) => value.length > 0));
|
||||
}
|
||||
|
||||
async function buildInstructionsPrefix(
|
||||
|
|
@ -591,7 +594,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
clearSession: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const reason = formatRunError(err);
|
||||
const error = formatRunError(err);
|
||||
const reason = !model && error.includes("[invalid_model]")
|
||||
? `${error} Cursor rejected its configured default model. Choose an available default at https://cursor.com/dashboard/cloud-agents or set this agent's model explicitly.`
|
||||
: error.includes("Failed to determine repository default branch")
|
||||
? `${error} Verify that Cursor's GitHub integration can access ${repoUrl} at https://cursor.com/dashboard/cloud-agents. If the repository has no default branch, configure a starting branch for this agent.`
|
||||
: error;
|
||||
if (run) {
|
||||
await onLog("stdout", eventLine({
|
||||
type: "cursor_cloud.result",
|
||||
|
|
|
|||
|
|
@ -271,3 +271,20 @@ describe("isPiUnknownSessionError", () => {
|
|||
expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("terminal provider failures", () => {
|
||||
it("surfaces and deduplicates errors from Pi assistant messages", () => {
|
||||
const message = { role: "assistant", content: [], stopReason: "error", errorMessage: "400 Context limit exceeded" };
|
||||
const parsed = parsePiJsonl([
|
||||
{ type: "message_end", message },
|
||||
{ type: "turn_end", message },
|
||||
{ type: "agent_end", messages: [message] },
|
||||
].map(event => JSON.stringify(event)).join("\n"));
|
||||
expect(parsed.errors).toEqual(["400 Context limit exceeded"]);
|
||||
});
|
||||
it("reports an error even when the provider omitted its message", () => {
|
||||
expect(parsePiJsonl(JSON.stringify({ type: "turn_end", message: { role: "assistant", stopReason: "error" } })).errors)
|
||||
.toEqual(["Pi provider request failed."]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,20 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput {
|
|||
|
||||
const eventType = asString(event.type, "");
|
||||
|
||||
// Pi can exit successfully after a provider failure. The terminal assistant
|
||||
// message carries that failure in both message_end and turn_end envelopes.
|
||||
const terminalMessages = eventType === "agent_end"
|
||||
? (Array.isArray(event.messages) ? event.messages : [])
|
||||
: eventType === "message_end" || eventType === "turn_end"
|
||||
? [event.message]
|
||||
: [];
|
||||
for (const rawMessage of terminalMessages) {
|
||||
const message = asRecord(rawMessage);
|
||||
if (message?.role !== "assistant" || message.stopReason !== "error") continue;
|
||||
const error = asString(message.errorMessage, "").trim() || "Pi provider request failed.";
|
||||
if (!result.errors.includes(error)) result.errors.push(error);
|
||||
}
|
||||
|
||||
// RPC protocol messages - skip these (internal implementation detail)
|
||||
if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -228,6 +228,22 @@ export const resetAgentSessionSchema = z.object({
|
|||
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
|
||||
|
||||
export const testAdapterEnvironmentSchema = z.object({
|
||||
/** One-shot provider keys for a probe. Never persist these in agent config. */
|
||||
testCredentials: z.object({
|
||||
ANTHROPIC_API_KEY: z.string().max(16384),
|
||||
OPENAI_API_KEY: z.string().max(16384),
|
||||
OPENROUTER_API_KEY: z.string().max(16384),
|
||||
GEMINI_API_KEY: z.string().max(16384),
|
||||
XAI_API_KEY: z.string().max(16384),
|
||||
GROQ_API_KEY: z.string().max(16384),
|
||||
OPENCODE_API_KEY: z.string().max(16384),
|
||||
CURSOR_API_KEY: z.string().max(16384),
|
||||
KIMI_MODEL_API_KEY: z.string().max(16384),
|
||||
API_SERVER_KEY: z.string().max(16384),
|
||||
ZAI_API_KEY: z.string().max(16384),
|
||||
KIMI_API_KEY: z.string().max(16384),
|
||||
MINIMAX_API_KEY: z.string().max(16384),
|
||||
}).partial().strict().optional(),
|
||||
adapterConfig: adapterConfigSchema.optional().default({}),
|
||||
/**
|
||||
* Optional environment to run the adapter test inside. When omitted, the
|
||||
|
|
|
|||
|
|
@ -277,11 +277,18 @@ describe("server adapter registry", () => {
|
|||
it.each([
|
||||
["claude", "claude-sonnet-5"],
|
||||
["codex", "gpt-5.6-sol"],
|
||||
] as const)("accepts the qualified ACPX %s environment profile", async (acpxAgent, model) => {
|
||||
] as const)("accepts the qualified remote ACPX %s environment profile", async (acpxAgent, model) => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent, model },
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
providerKey: "test-provider",
|
||||
runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -291,6 +298,28 @@ describe("server adapter registry", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["linux", "x64", "pass", "acpx_profile_qualified"],
|
||||
["darwin", "arm64", "fail", "acpx_runtime_platform_unsupported"],
|
||||
["linux", "arm64", "fail", "acpx_runtime_platform_unsupported"],
|
||||
])("checks local ACPX support on %s %s", async (platform, arch, status, code) => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, "arch")!;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { ...platformDescriptor, value: platform });
|
||||
Object.defineProperty(process, "arch", { ...archDescriptor, value: arch });
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
||||
});
|
||||
expect(result).toMatchObject({ status, checks: [expect.objectContaining({ code })] });
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
Object.defineProperty(process, "arch", archDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the ACPX Pi profile unavailable", async () => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,56 @@ describe("agent test-environment route", () => {
|
|||
await unregisterTestAdapter("external_test");
|
||||
});
|
||||
|
||||
it.each(["CURSOR_API_KEY", "KIMI_MODEL_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY"])("accepts %s as a probe-only credential", async (key) => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({ testCredentials: { [key]: "probe-only-key" } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({});
|
||||
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ [key]: "probe-only-key" });
|
||||
});
|
||||
|
||||
it("maps the Hermes gateway probe key without passing it to persistence", async () => {
|
||||
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
const previous = getServerAdapter("hermes_gateway");
|
||||
unregisterServerAdapter("hermes_gateway");
|
||||
registerServerAdapter({ ...externalAdapter, type: "hermes_gateway" });
|
||||
try {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/hermes_gateway/test-environment")
|
||||
.send({ adapterConfig: { apiBaseUrl: "https://hermes.example.com" }, testCredentials: { API_SERVER_KEY: "gateway-probe-key" } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ apiBaseUrl: "https://hermes.example.com" });
|
||||
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.apiKey).toBe("gateway-probe-key");
|
||||
expect(JSON.stringify(res.body)).not.toContain("gateway-probe-key");
|
||||
} finally {
|
||||
unregisterServerAdapter("hermes_gateway");
|
||||
if (previous) registerServerAdapter(previous);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes one-shot provider credentials only to the probe, never persistence normalization", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({ adapterConfig: { env: { KEEP: "value" } }, testCredentials: { OPENROUTER_API_KEY: "probe-only-key" } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSecretService.normalizeAdapterConfigForPersistence.mock.calls[0]?.[1]).toEqual({ env: { KEEP: "value" } });
|
||||
expect(testEnvironmentSpy.mock.calls[0]?.[0].config.env).toEqual({ KEEP: "value", OPENROUTER_API_KEY: "probe-only-key" });
|
||||
expect(JSON.stringify(res.body)).not.toContain("probe-only-key");
|
||||
});
|
||||
|
||||
it("rejects non-provider variables in one-shot credentials", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({ testCredentials: { NODE_OPTIONS: "--require unsafe" } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to a host probe when a requested environment cannot produce an execution target", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
toolApplications,
|
||||
toolConnectionInstalls,
|
||||
toolConnections,
|
||||
toolCatalogEntries,
|
||||
toolMcpGateways,
|
||||
toolMcpGatewayTokens,
|
||||
toolProfileBindings,
|
||||
|
|
@ -25,6 +26,8 @@ import {
|
|||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js";
|
||||
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
|
|
@ -203,6 +206,84 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
|
|||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves exact permissions when an aggregate assignment exceeds the public 250-entry edit limit", async () => {
|
||||
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
|
||||
const [company] = await db.insert(companies).values({
|
||||
name: "Large MCP assignment",
|
||||
issuePrefix: `LM${randomUUID().slice(0, 5).toUpperCase()}`,
|
||||
}).returning();
|
||||
const [agent, gatewayReader] = await db.insert(agents).values([
|
||||
{ companyId: company!.id, name: "Cursor Cloud", role: "engineer", adapterType: "cursor_cloud" },
|
||||
{ companyId: company!.id, name: "Gateway reader", role: "engineer", adapterType: "cursor_cloud" },
|
||||
]).returning();
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: company!.id, applicationKey: "large-mcp", name: "Large MCP", type: "mcp_http",
|
||||
}).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId: company!.id, applicationId: application!.id,
|
||||
name: "Large MCP", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true,
|
||||
config: { url: "https://large.example.test/mcp" },
|
||||
}).returning();
|
||||
const catalogInput = (name: string) => ({
|
||||
companyId: company!.id, applicationId: application!.id, connectionId: connection!.id,
|
||||
name, toolName: name, versionHash: "fixture", status: "active" as const,
|
||||
});
|
||||
const catalog = await db.insert(toolCatalogEntries).values([
|
||||
...Array.from({ length: 251 }, (_, index) => catalogInput(`allowed_${index}`)),
|
||||
catalogInput("excluded"), catalogInput("unassigned"),
|
||||
]).returning();
|
||||
const allowed = catalog.slice(0, 251);
|
||||
const excluded = catalog[251]!;
|
||||
// Multiple valid profiles can each have fewer than 250 entries while their
|
||||
// union exceeds the HTTP edit-request limit.
|
||||
const profiles = await db.insert(toolProfiles).values(["first", "second"].map((key) => ({
|
||||
companyId: company!.id, profileKey: key, name: key, defaultAction: "deny" as const,
|
||||
}))).returning();
|
||||
await db.insert(toolProfileEntries).values([
|
||||
...[...allowed, excluded].map((tool, index) => ({
|
||||
companyId: company!.id, profileId: profiles[index < 200 ? 0 : 1]!.id,
|
||||
selectorType: "catalog_entry" as const, effect: "include" as const,
|
||||
applicationId: application!.id, connectionId: connection!.id, catalogEntryId: tool.id,
|
||||
})),
|
||||
{
|
||||
companyId: company!.id, profileId: profiles[1]!.id,
|
||||
selectorType: "catalog_entry" as const, effect: "exclude" as const,
|
||||
applicationId: application!.id, connectionId: connection!.id, catalogEntryId: excluded.id,
|
||||
},
|
||||
]);
|
||||
await db.insert(toolProfileBindings).values(profiles.map((profile) => ({
|
||||
companyId: company!.id, profileId: profile.id, targetType: "agent" as const, targetId: agent!.id,
|
||||
})));
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId: company!.id, connectionId: connection!.id, targetType: "agent", targetId: agent!.id,
|
||||
});
|
||||
|
||||
const servers = await buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: randomUUID() });
|
||||
expect(servers).toHaveLength(1);
|
||||
const [gateway] = await db.select().from(toolMcpGateways);
|
||||
const generatedEntries = await db.select().from(toolProfileEntries)
|
||||
.where(eq(toolProfileEntries.profileId, gateway!.profileId!));
|
||||
expect(generatedEntries).toHaveLength(251);
|
||||
expect(generatedEntries.every((entry) => entry.selectorType === "catalog_entry")).toBe(true);
|
||||
expect(generatedEntries.map((entry) => entry.catalogEntryId).sort()).toEqual(allowed.map((tool) => tool.id).sort());
|
||||
|
||||
// Evaluate the generated profile independently of the original assignments,
|
||||
// including a tool discovered after the immutable profile was created.
|
||||
await db.insert(toolCatalogEntries).values(catalogInput("new_after_snapshot"));
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company!.id, profileId: gateway!.profileId!, targetType: "agent", targetId: gatewayReader!.id,
|
||||
});
|
||||
const effective = await toolAccessService(db).getEffectiveProfilesForAgent(company!.id, gatewayReader!.id);
|
||||
expect(effective.allowedTools.map((tool) => tool.id).sort()).toEqual(allowed.map((tool) => tool.id).sort());
|
||||
expect(effective.allowedToolNames).not.toContain("excluded");
|
||||
expect(effective.allowedToolNames).not.toContain("unassigned");
|
||||
expect(effective.allowedToolNames).not.toContain("new_after_snapshot");
|
||||
|
||||
const reused = await buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: randomUUID() });
|
||||
expect(reused[0]!.connectionId).toBe(servers[0]!.connectionId);
|
||||
expect(await db.select().from(toolMcpGateways)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exposes only the dedicated GitHub connection when a personal connection is also installed", async () => {
|
||||
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
|
||||
const [company] = await db.insert(companies).values({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { pinoHttp } from "pino-http";
|
|||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js";
|
||||
import { testAdapterEnvironmentSchema } from "@paperclipai/shared";
|
||||
import { createHttpLogger } from "../middleware/logger.js";
|
||||
|
||||
describe("HTTP logger redaction", () => {
|
||||
|
|
@ -165,4 +166,31 @@ describe("HTTP logger redaction", () => {
|
|||
metadata: { token: "[REDACTED]" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([400, 500])("redacts the complete probe credential container on HTTP %s", async (status) => {
|
||||
const chunks: string[] = [];
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream)));
|
||||
app.post("/probe", (req, res) => {
|
||||
if (status === 500) {
|
||||
(res as any).__errorContext = { error: { message: "probe failed" }, reqBody: req.body };
|
||||
}
|
||||
res.status(status).json({ error: "probe failed" });
|
||||
});
|
||||
const keys = Object.keys(testAdapterEnvironmentSchema.shape.testCredentials.unwrap().shape);
|
||||
const credentials = Object.fromEntries([...keys, "UNKNOWN_PROVIDER_KEY"].map((key) => [key, `canary-${key}`]));
|
||||
await request(app).post("/probe").send({ adapterConfig: { model: "default" }, testCredentials: credentials });
|
||||
const output = chunks.join("");
|
||||
expect(output).not.toContain("canary-");
|
||||
expect(JSON.parse(output.trim()).reqBody).toEqual({
|
||||
adapterConfig: { model: "default" }, testCredentials: "[REDACTED]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils";
|
||||
import { listServerAdapters, requireServerAdapter } from "./registry.js";
|
||||
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
|
||||
// The registry registers a login capability for the two built-in interactive
|
||||
|
|
@ -79,3 +80,81 @@ describe("built-in runtime connection tool delivery", () => {
|
|||
expect(requireServerAdapter(type).runtimeToolDelivery).toBe(strategy);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("native ACPX environment checks", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const context = {
|
||||
companyId: "company-test",
|
||||
adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
||||
};
|
||||
|
||||
it("reports unsupported local platforms before a successful CLI login can mask them", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context);
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks).toEqual([expect.objectContaining({
|
||||
code: "acpx_runtime_platform_unsupported",
|
||||
level: "error",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("keeps the qualified Linux x64 profile available", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
vi.spyOn(process, "arch", "get").mockReturnValue("x64");
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context);
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("does not use the host platform to reject a remote environment", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({
|
||||
...context,
|
||||
executionTarget: {
|
||||
kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "test",
|
||||
runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) },
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
const sshTarget = {
|
||||
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/workspace",
|
||||
spec: {
|
||||
host: "example.test", port: 22, username: "tester", remoteCwd: "/workspace",
|
||||
remoteWorkspacePath: "/workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
["Linux\nx86_64\n", "pass"],
|
||||
["Darwin\nx86_64\n", "fail"],
|
||||
["Linux\naarch64\n", "fail"],
|
||||
["", "fail"],
|
||||
])("qualifies the SSH platform from its own uname output %j", async (stdout, status) => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
vi.spyOn(process, "arch", "get").mockReturnValue("x64");
|
||||
const probe = vi.spyOn(executionTarget, "runAdapterExecutionTargetShellCommand").mockResolvedValue({
|
||||
exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date(0).toISOString(),
|
||||
});
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, executionTarget: sshTarget });
|
||||
expect(result.status).toBe(status);
|
||||
expect(probe).toHaveBeenCalledWith(expect.any(String), sshTarget, "uname -s && uname -m", {
|
||||
cwd: "/workspace", env: {}, timeoutSec: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["timeout", "exit", "exception"])("does not qualify an SSH target after a probe %s", async (failure) => {
|
||||
const probe = vi.spyOn(executionTarget, "runAdapterExecutionTargetShellCommand");
|
||||
if (failure === "exception") probe.mockRejectedValue(new Error("connection unavailable"));
|
||||
else probe.mockResolvedValue({
|
||||
exitCode: failure === "exit" ? 1 : 0, timedOut: failure === "timeout",
|
||||
stdout: "Linux\nx86_64\n", stderr: "", signal: null, pid: null, startedAt: new Date(0).toISOString(),
|
||||
});
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, executionTarget: sshTarget });
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks[0].code).toBe("acpx_runtime_platform_unverified");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
|
||||
import { runAdapterExecutionTargetShellCommand } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
execute as claudeExecute,
|
||||
listClaudeSkills,
|
||||
|
|
@ -403,6 +404,48 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
|||
};
|
||||
}
|
||||
if (profile.provider === "acpx") {
|
||||
// The pinned ACPX executables are qualified for Linux x64. A host CLI
|
||||
// login probe can succeed on macOS even though runner admission cannot.
|
||||
let supported = process.platform === "linux" && process.arch === "x64";
|
||||
const target = context.executionTarget;
|
||||
if (target?.kind === "remote") {
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
`acpx-platform-${crypto.randomUUID()}`,
|
||||
target,
|
||||
"uname -s && uname -m",
|
||||
{ cwd: target.remoteCwd, env: {}, timeoutSec: 15 },
|
||||
);
|
||||
if (probe.timedOut || probe.exitCode !== 0) throw new Error("Platform probe failed");
|
||||
const [os, arch] = probe.stdout.trim().split(/\s+/);
|
||||
supported = os === "Linux" && arch === "x86_64";
|
||||
} catch {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt: new Date().toISOString(),
|
||||
checks: [{
|
||||
code: "acpx_runtime_platform_unverified",
|
||||
level: "error" as const,
|
||||
message: "Could not verify the remote ACPX runner platform.",
|
||||
hint: "Check the environment connection and retry. The native ACPX runner requires Linux x64.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!supported) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt: new Date().toISOString(),
|
||||
checks: [{
|
||||
code: "acpx_runtime_platform_unsupported",
|
||||
level: "error" as const,
|
||||
message: `The native ACPX ${profile.acpxAgent} runner requires a Linux x64 environment.`,
|
||||
hint: `Select a Linux x64 environment, or use the regular ${profile.acpxAgent === "claude" ? "Claude Code" : "Codex"} adapter on this machine.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "pass" as const,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ const SENSITIVE_KEYS = new Set<string>([
|
|||
"idtoken",
|
||||
"api_key",
|
||||
"apikey",
|
||||
// Probe keys are transient, including rejected or future provider names.
|
||||
// Redact the whole container instead of maintaining a second key allowlist.
|
||||
"testcredentials",
|
||||
"authorization",
|
||||
"auth_token",
|
||||
"authtoken",
|
||||
|
|
|
|||
|
|
@ -3128,6 +3128,19 @@ export function agentRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
// Probe-only credentials bypass storage, not authorization. The schema
|
||||
// allows only provider key names; they never enter persisted config.
|
||||
if (req.body.testCredentials) {
|
||||
effectiveAdapterConfig = {
|
||||
...effectiveAdapterConfig,
|
||||
env: { ...parseObject(effectiveAdapterConfig.env), ...req.body.testCredentials },
|
||||
};
|
||||
// Hermes authenticates the gateway itself through a top-level field.
|
||||
// Keep its draft key out of persistence normalization, like env keys.
|
||||
if (type === "hermes_gateway" && req.body.testCredentials.API_SERVER_KEY) {
|
||||
effectiveAdapterConfig.apiKey = req.body.testCredentials.API_SERVER_KEY;
|
||||
}
|
||||
}
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
|
|
|
|||
|
|
@ -4291,11 +4291,10 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
|||
catalogEntryId: tool.id,
|
||||
})),
|
||||
];
|
||||
if (entries.length > 250) {
|
||||
throw new Error(
|
||||
"native MCP assignment exceeds the 250-entry gateway profile limit",
|
||||
);
|
||||
}
|
||||
// The 250-entry limit bounds a public profile-edit request, not the
|
||||
// effective assignment assembled from existing profiles. Keep every exact
|
||||
// selector here: truncating or replacing them with connection-wide grants
|
||||
// would either lose assigned tools or authorize tools outside this snapshot.
|
||||
try {
|
||||
const created = await access.createProfile(input.agent.companyId, {
|
||||
profileKey,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Adapter brand icons
|
||||
|
||||
Source: [Lobe Icons](https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons), pinned to commit `a94750e3f5f8fc33757b839d85030e742284e43a`.
|
||||
|
||||
Gemini and Kimi use the color variants. Cursor, Grok, Hermes Agent, and Pi Agent use their monochrome brand marks. Dark variants replace `currentColor` with white for use as standalone images; the Kimi light variant changes its white letterform to black. All other artwork is unchanged. Pi is the coding agent at pi.dev.
|
||||
|
||||
Artwork is distributed under the accompanying MIT license. Brand names and marks belong to their respective owners.
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 542 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 751 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#000"></path></svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>
|
||||
|
After Width: | Height: | Size: 773 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>
|
||||
|
After Width: | Height: | Size: 313 B |
|
|
@ -114,9 +114,6 @@ const CompanyExport = lazy(() =>
|
|||
const ProductionAgents = lazy(() =>
|
||||
import("./pages/Agents.production").then((module) => ({ default: module.Agents })),
|
||||
);
|
||||
const ProductionAgentDetail = lazy(() =>
|
||||
import("./pages/AgentDetail.production").then((module) => ({ default: module.AgentDetail })),
|
||||
);
|
||||
const ProductionRoutines = lazy(() =>
|
||||
import("./pages/Routines.production").then((module) => ({ default: module.Routines })),
|
||||
);
|
||||
|
|
@ -267,9 +264,9 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
|
|||
/>
|
||||
))}
|
||||
<Route path="agents/new" element={<NewAgent />} />
|
||||
<Route path="agents/:agentId" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
|
||||
<Route path="agents/:agentId/:tab" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
|
||||
<Route path="agents/:agentId/runs/:runId" element={streamlinedUiEnabled ? <AgentDetail /> : <ProductionSurface><ProductionAgentDetail /></ProductionSurface>} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export const agentsApi = {
|
|||
type: string,
|
||||
data: {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId?: string | null;
|
||||
},
|
||||
) =>
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export function AgentActionButtons({
|
|||
assignLabel = "Assign Task",
|
||||
runLabel = "Run now",
|
||||
showStatus = true,
|
||||
showRun = true,
|
||||
actionsDisabled = false,
|
||||
workActionsDisabled = false,
|
||||
workActionsDisabledReason,
|
||||
|
|
@ -182,6 +183,7 @@ export function AgentActionButtons({
|
|||
assignLabel?: string;
|
||||
runLabel?: string;
|
||||
showStatus?: boolean;
|
||||
showRun?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
workActionsDisabled?: boolean;
|
||||
workActionsDisabledReason?: string;
|
||||
|
|
@ -398,7 +400,7 @@ export function AgentActionButtons({
|
|||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
{showRun && <RunButton
|
||||
onClick={() => {
|
||||
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
||||
agentAction.mutate("invoke");
|
||||
|
|
@ -406,7 +408,7 @@ export function AgentActionButtons({
|
|||
disabled={assignAndRunDisabled}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
/>}
|
||||
{canRunWithProviderTrace && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -262,6 +262,11 @@ async function renderForm(
|
|||
options: {
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
content?: "configuration" | "secrets";
|
||||
environmentVariablesPlacement?: "configuration" | "secrets";
|
||||
hideInlineSave?: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||
} = {},
|
||||
) {
|
||||
mockEnvironmentsApi.list.mockResolvedValue(environments);
|
||||
|
|
@ -288,6 +293,11 @@ async function renderForm(
|
|||
onSave={onSave}
|
||||
hidePromptTemplate
|
||||
content={options.content}
|
||||
environmentVariablesPlacement={options.environmentVariablesPlacement}
|
||||
hideInlineSave={options.hideInlineSave}
|
||||
onDirtyChange={options.onDirtyChange}
|
||||
onSaveActionChange={options.onSaveActionChange}
|
||||
onCancelActionChange={options.onCancelActionChange}
|
||||
showAdapterTypeField={false}
|
||||
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
|
||||
/>
|
||||
|
|
@ -729,6 +739,51 @@ describe("AgentConfigForm environment selector", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => {
|
||||
const dirty = vi.fn();
|
||||
let save: (() => void) | null = null;
|
||||
let discard: (() => void) | null = null;
|
||||
const result = await renderForm([], {}, {
|
||||
content: "secrets", environmentVariablesPlacement: "secrets", hideInlineSave: true,
|
||||
onDirtyChange: dirty,
|
||||
onSaveActionChange: action => { save = action; },
|
||||
onCancelActionChange: action => { discard = action; },
|
||||
});
|
||||
roots.push(result.root);
|
||||
const add = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add variable")!;
|
||||
await act(async () => add.click());
|
||||
await act(async () => {
|
||||
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!, "ONBOARDING_SMOKE");
|
||||
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!, "true");
|
||||
});
|
||||
await flushReact();
|
||||
expect(dirty).toHaveBeenLastCalledWith(true);
|
||||
expect([...result.container.querySelectorAll("button")].some(button => button.textContent?.trim() === "Save")).toBe(false);
|
||||
await act(async () => { await save?.(); });
|
||||
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ env: { ONBOARDING_SMOKE: { type: "plain", value: "true" } } }) }));
|
||||
await act(async () => discard?.());
|
||||
await flushReact();
|
||||
expect(dirty).toHaveBeenLastCalledWith(false);
|
||||
expect(result.container.querySelector('input[aria-label="Variable name"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("reads and saves Pi thinking effort using the Pi runtime key", async () => {
|
||||
const result = await renderForm([], { adapterType: "pi_local", adapterConfig: { model: "openrouter/anthropic/claude-sonnet-4.6", thinking: "high" } });
|
||||
roots.push(result.root);
|
||||
const effort = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "high")!;
|
||||
expect(effort).toBeTruthy();
|
||||
await act(async () => effort.click());
|
||||
await flushReact();
|
||||
const low = [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "lowlow")!;
|
||||
expect(low).toBeTruthy();
|
||||
await act(async () => low.click());
|
||||
await flushReact();
|
||||
const save = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!;
|
||||
await act(async () => save.click());
|
||||
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ thinking: "low" }) }));
|
||||
expect(result.onSave.mock.calls[0][0].adapterConfig.effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the environment override when Local is the only configured environment", async () => {
|
||||
const result = await renderForm([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { testAgentSetup } from "@/lib/test-agent-setup";
|
||||
import { RuntimeTestCard } from "./RuntimeTestCard";
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
|
|
@ -118,6 +120,7 @@ type AgentConfigFormProps = {
|
|||
hideInlineSave?: boolean;
|
||||
showAdapterTypeField?: boolean;
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
compactTestFeedback?: boolean;
|
||||
showCreateRunPolicySection?: boolean;
|
||||
hideInstructionsFile?: boolean;
|
||||
/** Allow instance administrators to configure short-lived raw provider capture. */
|
||||
|
|
@ -126,8 +129,13 @@ type AgentConfigFormProps = {
|
|||
hidePromptTemplate?: boolean;
|
||||
/** Render the main configuration sections or the dedicated edit-only Secrets surface. */
|
||||
content?: "configuration" | "secrets";
|
||||
/** Keep variable bindings beside secret access in a unified edit surface. */
|
||||
environmentVariablesPlacement?: "configuration" | "secrets";
|
||||
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||
sectionLayout?: "inline" | "cards";
|
||||
/** Optional settings composition; sorting changes DOM order as well as visual order. */
|
||||
sectionOrder?: readonly string[];
|
||||
sectionTitles?: Record<string, string>;
|
||||
} & (
|
||||
| {
|
||||
mode: "create";
|
||||
|
|
@ -246,6 +254,23 @@ function clampDelayMsFromSeconds(value: number) {
|
|||
return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000;
|
||||
}
|
||||
|
||||
function ConfigSections({ order, className, children }: {
|
||||
order?: readonly string[];
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!order) return <div className={className}>{children}</div>;
|
||||
const rank = (child: ReactNode) => {
|
||||
const key = isValidElement<{ "data-config-section"?: string }>(child)
|
||||
? child.props["data-config-section"]
|
||||
: undefined;
|
||||
const index = key ? order.indexOf(key) : -1;
|
||||
return index < 0 ? order.length : index;
|
||||
};
|
||||
const sections = Children.toArray(children).sort((a, b) => rank(a) - rank(b));
|
||||
return <div className={className}>{sections}</div>;
|
||||
}
|
||||
|
||||
/* ---- Form ---- */
|
||||
|
||||
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
|
@ -255,7 +280,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const showAdapterTypeField = props.showAdapterTypeField ?? true;
|
||||
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
|
||||
const showInlineAdapterTestEnvironmentButton =
|
||||
showAdapterTestEnvironmentButton && !props.onTestActionChange;
|
||||
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
|
||||
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
|
||||
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
|
||||
const hideInstructionsFile = props.hideInstructionsFile ?? false;
|
||||
|
|
@ -380,6 +405,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
|
||||
// ---- Edit mode: overlay for dirty tracking ----
|
||||
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
|
||||
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
|
||||
const [environmentEditorKey, setEnvironmentEditorKey] = useState(0);
|
||||
const agentRef = useRef<Agent | null>(null);
|
||||
|
||||
// Clear overlay when agent data refreshes (after save)
|
||||
|
|
@ -392,7 +419,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isDirty = !isCreate && isOverlayDirty(overlay);
|
||||
const isDirty = !isCreate && (isOverlayDirty(overlay) || environmentDraftDirty);
|
||||
|
||||
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
|
||||
|
||||
|
|
@ -441,6 +468,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
/** Build accumulated patch and send to parent */
|
||||
const handleCancel = useCallback(() => {
|
||||
setOverlay({ ...emptyOverlay });
|
||||
setEnvironmentDraftDirty(false);
|
||||
setEnvironmentEditorKey(key => key + 1);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
|
|
@ -884,10 +913,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
// managed sandbox instead of sending the hidden local id to the server.
|
||||
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
|
||||
});
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
|
||||
adapterConfig: buildAdapterConfigForTest(adapterConfigPatch),
|
||||
environmentId,
|
||||
});
|
||||
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
|
||||
if (props.compactTestFeedback) {
|
||||
const providerAdapter = adapterType === "paperclip_runner"
|
||||
? adapterConfig.provider === "codex" ? "codex_local"
|
||||
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
|
||||
: adapterType
|
||||
: adapterType;
|
||||
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, environmentId });
|
||||
}
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, environmentId });
|
||||
},
|
||||
});
|
||||
const [testActionPending, setTestActionPending] = useState(false);
|
||||
|
|
@ -1083,7 +1118,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
: adapterType === "pi_local" ? "thinking" : "effort";
|
||||
const thinkingEffortOptions =
|
||||
adapterType === "codex_local"
|
||||
? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({
|
||||
|
|
@ -1096,7 +1131,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? openCodeThinkingEffortOptions
|
||||
: adapterType === "kimi_local"
|
||||
? kimiThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
: adapterType === "pi_local"
|
||||
? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))]
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
: adapterType === "codex_local"
|
||||
|
|
@ -1109,7 +1146,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local"
|
||||
&& adapterType !== "cursor_cloud"
|
||||
&& adapterType !== "paperclip_runner";
|
||||
|
|
@ -1157,6 +1194,38 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
});
|
||||
}
|
||||
|
||||
const environmentVariablesEditor = (
|
||||
<EnvironmentVariablesEditor
|
||||
ref={environmentVariablesEditorRef}
|
||||
key={environmentEditorKey}
|
||||
onDirtyChange={setEnvironmentDraftDirty}
|
||||
hideDraftActions={!isCreate && props.hideInlineSave}
|
||||
value={
|
||||
isCreate
|
||||
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
|
||||
: (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
||||
}
|
||||
secrets={availableSecrets}
|
||||
userSecretDefinitions={userSecretDefinitions}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
}}
|
||||
onChange={(env) =>
|
||||
isCreate
|
||||
? set!({ envBindings: env ?? {}, envVars: "" })
|
||||
: mark("adapterConfig", "env", env)
|
||||
}
|
||||
/>
|
||||
);
|
||||
const environmentVariablesField = (
|
||||
<div data-config-field="environment-variables">
|
||||
<Field label="Environment variables" hint={help.envVars}>
|
||||
{environmentVariablesEditor}
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isCreate && props.content === "secrets") {
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
|
|
@ -1171,7 +1240,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{props.environmentVariablesPlacement === "secrets" && (
|
||||
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="mb-3 text-sm font-medium">Environment variables</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
|
||||
}
|
||||
<div className={cn(cards ? "rounded-lg border border-border p-4" : "px-4 pb-3")}>
|
||||
{environmentVariablesEditor}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div data-config-section="secrets" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
|
||||
|
|
@ -1195,7 +1276,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
{isDirty && !props.hideInlineSave && (
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
|
||||
|
|
@ -1214,9 +1295,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
|
||||
{/* ---- Identity (edit only) ---- */}
|
||||
{!isCreate && (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
<div data-config-section="identity" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Identity</h3>
|
||||
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["identity"] ?? "Identity"}</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
|
|
@ -1295,7 +1376,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
// Instance execution policy forces the managed Kubernetes sandbox
|
||||
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
|
||||
// Render the environment read-only instead of the selectable picker.
|
||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Environment</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
|
||||
|
|
@ -1320,7 +1401,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</div>
|
||||
</div>
|
||||
) : showEnvironmentOverrideControl ? (
|
||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Environment</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
|
||||
|
|
@ -1354,10 +1435,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
) : null}
|
||||
|
||||
{/* ---- Adapter ---- */}
|
||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
<div data-config-section="adapter" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium">Adapter</h3>
|
||||
? <h3 className="text-sm font-medium">{props.sectionTitles?.["adapter"] ?? "Adapter"}</h3>
|
||||
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
|
||||
}
|
||||
{showInlineAdapterTestEnvironmentButton && (
|
||||
|
|
@ -1443,7 +1524,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
{showInlineAdapterTestEnvironmentFeedback && (testActionError || testEnvironment.error) && (
|
||||
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{testActionError
|
||||
?? (testEnvironment.error instanceof Error
|
||||
|
|
@ -1452,7 +1533,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && (
|
||||
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && testEnvironment.data && (
|
||||
<AdapterEnvironmentResult result={testEnvironment.data} />
|
||||
)}
|
||||
|
||||
|
|
@ -1503,9 +1584,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
|
||||
{/* ---- Permissions & Configuration ---- */}
|
||||
{isLocal && (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
<div data-config-section="permissions" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Permissions & Configuration</h3>
|
||||
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["permissions"] ?? "Permissions & Configuration"}</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions & Configuration</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
|
|
@ -1521,39 +1602,41 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
flashes on a managed instance.
|
||||
*/}
|
||||
{!hideHostPaths && (
|
||||
<Field label="Command" hint={help.localCommand}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? val!.command
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
adapterCommandField,
|
||||
String(
|
||||
config.command ?? "",
|
||||
),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", adapterCommandField, v || null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder={
|
||||
({
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
kimi_local: "kimi",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div data-config-field="command">
|
||||
<Field label="Command" hint={help.localCommand}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? val!.command
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
adapterCommandField,
|
||||
String(
|
||||
config.command ?? "",
|
||||
),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", adapterCommandField, v || null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder={
|
||||
({
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
kimi_local: "kimi",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelDropdown
|
||||
|
|
@ -1579,9 +1662,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "pi_local"}
|
||||
groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}
|
||||
creatable
|
||||
detectedModel={detectedModel}
|
||||
detectedModelCandidates={[]}
|
||||
|
|
@ -1686,27 +1769,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Environment variables" hint={help.envVars}>
|
||||
<EnvironmentVariablesEditor
|
||||
ref={environmentVariablesEditorRef}
|
||||
value={
|
||||
isCreate
|
||||
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
|
||||
: (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
||||
}
|
||||
secrets={availableSecrets}
|
||||
userSecretDefinitions={userSecretDefinitions}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
}}
|
||||
onChange={(env) =>
|
||||
isCreate
|
||||
? set!({ envBindings: env ?? {}, envVars: "" })
|
||||
: mark("adapterConfig", "env", env)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{props.environmentVariablesPlacement !== "secrets" && environmentVariablesField}
|
||||
|
||||
{/* Edit-only: timeout + grace period */}
|
||||
{!isCreate && (
|
||||
|
|
@ -1743,7 +1806,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
|
||||
{/* ---- Run Policy ---- */}
|
||||
{isCreate && showCreateRunPolicySection ? (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
||||
|
|
@ -1764,7 +1827,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</div>
|
||||
</div>
|
||||
) : !isCreate ? (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
||||
|
|
@ -1905,7 +1968,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
|
||||
<RuntimeTestCard
|
||||
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
|
||||
result={testResult ?? null}
|
||||
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
|
||||
onTest={triggerTestEnvironment}
|
||||
disabled={testEnvironmentDisabled}
|
||||
/>
|
||||
)}
|
||||
</ConfigSections>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@ export function AgentContextualSidebar({
|
|||
agentRef,
|
||||
agentId,
|
||||
agentName,
|
||||
labels = { secrets: "Secrets & variables" },
|
||||
}: {
|
||||
agentRef: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
labels?: Partial<Record<AgentLocalDetailView, string>>;
|
||||
}) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const shouldResolveAgent = !agentId || !agentName;
|
||||
|
|
@ -96,7 +98,7 @@ export function AgentContextualSidebar({
|
|||
<SidebarNavItem
|
||||
key={item.value}
|
||||
to={href}
|
||||
label={item.label}
|
||||
label={labels?.[item.value] ?? item.label}
|
||||
icon={localIcons[item.value]}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -870,7 +870,7 @@ describe("Layout", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("keeps global navigation on legacy Agent and Routine detail routes", async () => {
|
||||
it("keeps global navigation with agent configuration navigation in the legacy shell", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableApps: true,
|
||||
enableStreamlinedUi: false,
|
||||
|
|
@ -895,7 +895,8 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Agent sidebar");
|
||||
if (pathname.includes("/agents/")) expect(container.textContent).toContain("Agent sidebar");
|
||||
else expect(container.textContent).not.toContain("Agent sidebar");
|
||||
expect(container.textContent).not.toContain("Routine sidebar");
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export function Layout() {
|
|||
/>
|
||||
)
|
||||
) : null;
|
||||
const secondarySidebar = streamlinedUiEnabled && shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
||||
const secondarySidebar = shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
||||
<AgentContextualSidebar agentRef={agentId} />
|
||||
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
|
||||
<RoutineContextualSidebar routineId={routineId} />
|
||||
|
|
|
|||
|
|
@ -1,283 +1,192 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
|
||||
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
|
||||
const getInviteOnboardingMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listAdaptersMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const closeNewAgentMock = vi.hoisted(() => vi.fn());
|
||||
const openNewIssueMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: {
|
||||
getExperimental: async () => ({ enableNativeRunner: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const invites = vi.hoisted(() => ({ createCompanyInvite: vi.fn(), getInviteOnboarding: vi.fn(), copy: vi.fn() }));
|
||||
vi.mock("../api/access", () => ({ accessApi: invites }));
|
||||
vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: invites.copy }));
|
||||
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
|
||||
const state = vi.hoisted(() => ({
|
||||
adapters: [] as object[],
|
||||
navigate: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate }));
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
newAgentOpen: true,
|
||||
closeNewAgent: closeNewAgentMock,
|
||||
openNewIssue: openNewIssueMock,
|
||||
}),
|
||||
useDialog: () => ({ newAgentOpen: true, closeNewAgent: state.close }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
vi.mock("@/api/adapters", () => ({
|
||||
adaptersApi: { list: async () => state.adapters },
|
||||
}));
|
||||
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: {
|
||||
createCompanyInvite: (companyId: string, input: unknown) =>
|
||||
createCompanyInviteMock(companyId, input),
|
||||
getInviteOnboarding: (token: string) => getInviteOnboardingMock(token),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => listAgentsMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/adapters", () => ({
|
||||
adaptersApi: {
|
||||
list: () => listAdaptersMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../adapters", () => ({
|
||||
listUIAdapters: () => [
|
||||
{ type: "claude_local" },
|
||||
{ type: "paperclip_runner" },
|
||||
{ type: "openclaw_gateway" },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/metadata", () => ({
|
||||
isVisualAdapterChoice: (type: string) => type !== "openclaw_gateway",
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/use-disabled-adapters", () => ({
|
||||
useDisabledAdaptersSync: () => new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mock("./onboarding/PillGuy", () => ({ PillGuy: () => null }));
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let cache: QueryClient;
|
||||
async function click(label: string) {
|
||||
const button = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === label,
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
await act(async () => button!.click());
|
||||
}
|
||||
|
||||
describe("NewAgentDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{ id: "agent-ceo", role: "ceo" },
|
||||
]);
|
||||
listAdaptersMock.mockResolvedValue([]);
|
||||
createCompanyInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
token: "agent-token",
|
||||
inviteUrl: "https://paperclip.local/invite/agent-token",
|
||||
expiresAt: "2026-04-20T00:00:00.000Z",
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
onboardingTextUrl: "https://paperclip.local/api/invites/agent-token/onboarding.txt",
|
||||
onboardingTextPath: "/api/invites/agent-token/onboarding.txt",
|
||||
});
|
||||
getInviteOnboardingMock.mockResolvedValue({
|
||||
onboarding: {
|
||||
connectivity: {
|
||||
connectionCandidates: ["https://paperclip.local"],
|
||||
testResolutionEndpoint: {
|
||||
url: "https://paperclip.local/api/invites/agent-token/test-resolution",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteTextMock },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("generates an external agent onboarding prompt inside the add-agent modal", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Add a new agent");
|
||||
expect(container.textContent).toContain("Invite an external agent");
|
||||
|
||||
const inviteButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Invite an external agent"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Generate a one-time onboarding prompt");
|
||||
expect(container.textContent).not.toContain("Organization Invites");
|
||||
|
||||
const generateButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Generate onboarding prompt",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
agentMessage: null,
|
||||
});
|
||||
expect(getInviteOnboardingMock).toHaveBeenCalledWith("agent-token");
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("You're invited to join a Paperclip company as an agent."),
|
||||
);
|
||||
expect(container.textContent).toContain("Agent onboarding prompt");
|
||||
expect(container.textContent).toContain("Send this prompt to the external agent");
|
||||
expect(container.textContent).not.toContain("Optional message for the agent");
|
||||
expect(container.textContent).not.toContain("Generate onboarding prompt");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "Agent invite created",
|
||||
body: "Agent onboarding prompt ready below and copied to clipboard.",
|
||||
tone: "success",
|
||||
});
|
||||
|
||||
const backButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Back",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Optional message for the agent");
|
||||
expect(container.textContent).toContain("Generate onboarding prompt");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Paperclip Runner configuration until the server enables it", async () => {
|
||||
listAdaptersMock.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: true },
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const configureButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Configure a runtime manually"),
|
||||
);
|
||||
await act(async () => {
|
||||
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).not.toContain("Paperclip Runner");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Paperclip Runner configuration after the server enables it", async () => {
|
||||
listAdaptersMock.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const configureButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Configure a runtime manually"),
|
||||
);
|
||||
await act(async () => {
|
||||
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip Runner");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
invites.createCompanyInvite.mockResolvedValue({ token: "one-time-token", onboardingTextPath: "/api/invites/one-time-token/onboarding.txt" });
|
||||
invites.getInviteOnboarding.mockResolvedValue({ onboarding: { connectivity: {} } });
|
||||
invites.copy.mockResolvedValue(undefined);
|
||||
state.adapters = [
|
||||
{ type: "codex_local", loaded: true },
|
||||
{ type: "paperclip_runner", loaded: true },
|
||||
{ type: "claude_local", loaded: true, disabled: true },
|
||||
];
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
cache = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<QueryClientProvider client={cache}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
cache.clear();
|
||||
container.remove();
|
||||
});
|
||||
async function name() {
|
||||
const input = document.querySelector("input")!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!.call(input, "Ada & Co");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await click("Choose adapter");
|
||||
}
|
||||
it("requires a name and an enabled adapter before navigating", async () => {
|
||||
await click("Choose adapter");
|
||||
expect(document.body.textContent).toContain("Agent name");
|
||||
await name();
|
||||
expect(document.querySelector('input[value="claude_local"]')).toBeNull();
|
||||
await click("Configure agent");
|
||||
expect(state.navigate).not.toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
(
|
||||
document.querySelector('input[value="codex_local"]') as HTMLInputElement
|
||||
).click(),
|
||||
);
|
||||
await click("Configure agent");
|
||||
expect(state.close).toHaveBeenCalledTimes(1);
|
||||
const query = new URL(state.navigate.mock.calls[0][0], "http://local")
|
||||
.searchParams;
|
||||
expect(query.get("name")).toBe("Ada & Co");
|
||||
expect(query.get("adapterType")).toBe("codex_local");
|
||||
});
|
||||
it("offers native Codex, Claude ACPX, and OpenCode runners", async () => {
|
||||
await name();
|
||||
await act(async () =>
|
||||
(
|
||||
document.querySelector(
|
||||
'input[value="paperclip_runner"]',
|
||||
) as HTMLInputElement
|
||||
).click(),
|
||||
);
|
||||
const options = [...document.querySelectorAll("option")].map(
|
||||
(option) => option.textContent,
|
||||
);
|
||||
expect(options).toContain("Codex (app server)");
|
||||
expect(options).toContain("Claude (ACPX)");
|
||||
expect(options).toContain("OpenCode");
|
||||
expect(options.join(" ")).not.toContain("ACPX Codex");
|
||||
});
|
||||
|
||||
it.each([false, undefined])(
|
||||
"hides the runner unless explicitly enabled (%s)",
|
||||
async (enableNativeRunner) => {
|
||||
await act(async () => {
|
||||
cache.setQueryData(queryKeys.instance.experimentalSettings, {
|
||||
enableNativeRunner,
|
||||
});
|
||||
});
|
||||
await name();
|
||||
expect(
|
||||
document.querySelector('input[value="paperclip_runner"]'),
|
||||
).toBeNull();
|
||||
expect(document.querySelector('input[value="codex_local"]')).not.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabled", async () => {
|
||||
await act(async () => {
|
||||
cache.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
cloud: { managed: true },
|
||||
});
|
||||
cache.setQueryData(
|
||||
queryKeys.adapters.all,
|
||||
[
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"cursor",
|
||||
"cursor_cloud",
|
||||
"gemini_local",
|
||||
"grok_local",
|
||||
"kimi_local",
|
||||
"pi_local",
|
||||
"hermes_local",
|
||||
"paperclip_runner",
|
||||
].map((type) => ({ type, loaded: true })),
|
||||
);
|
||||
});
|
||||
await name();
|
||||
expect(
|
||||
[...document.querySelectorAll<HTMLInputElement>('input[type="radio"]')].map(
|
||||
(input) => input.value,
|
||||
),
|
||||
).toEqual(["claude_local", "codex_local", "opencode_local"]);
|
||||
expect(document.body.textContent).not.toContain("CLI harness");
|
||||
});
|
||||
|
||||
it("keeps agent-only invitations reachable from the new-agent flow", async () => {
|
||||
await click("Invite an external agent");
|
||||
const message = document.querySelector("textarea")!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(message, "Help with research");
|
||||
message.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await click("Generate onboarding prompt");
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(invites.createCompanyInvite).toHaveBeenCalledWith("company-1", {
|
||||
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
|
||||
});
|
||||
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
||||
expect(invites.copy).toHaveBeenCalled();
|
||||
expect(state.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the generated invitation readable when clipboard access fails", async () => {
|
||||
invites.copy.mockRejectedValue(new Error("Clipboard unavailable"));
|
||||
invites.getInviteOnboarding.mockRejectedValue(new Error("Manifest unavailable"));
|
||||
await click("Invite an external agent");
|
||||
await click("Generate onboarding prompt");
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(document.body.textContent).toContain("Copy the prompt manually");
|
||||
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,421 +1,30 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { ExternalAgentInviteDialog } from "./new-agent/ExternalAgentInviteDialog";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { adaptersApi } from "../api/adapters";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Check,
|
||||
MailPlus,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { isVisualAdapterChoice } from "../adapters/metadata";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Adapter types that are suitable for agent creation (excludes internal
|
||||
* system adapters like "process" and "http").
|
||||
*/
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
|
||||
type NewAgentDialogMode = "choices" | "runtime" | "invite" | "prompt";
|
||||
|
||||
function isAgentAdapterType(type: string): boolean {
|
||||
return !SYSTEM_ADAPTER_TYPES.has(type);
|
||||
}
|
||||
import { AgentBasicsDialog } from "./new-agent/AgentBasicsDialog";
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { pushToast } = useToast();
|
||||
if (!newAgentOpen) return null;
|
||||
return <NewAgentDialogContent key={selectedCompanyId} companyId={selectedCompanyId} onClose={closeNewAgent} />;
|
||||
}
|
||||
|
||||
function NewAgentDialogContent({ companyId, onClose }: { companyId: string | null; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [mode, setMode] = useState<NewAgentDialogMode>("choices");
|
||||
const [agentMessage, setAgentMessage] = useState("");
|
||||
const [latestAgentPrompt, setLatestAgentPrompt] = useState<string | null>(null);
|
||||
const [latestAgentPromptCopied, setLatestAgentPromptCopied] = useState(false);
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
function resetDialogState() {
|
||||
setMode("choices");
|
||||
setAgentMessage("");
|
||||
setLatestAgentPrompt(null);
|
||||
setLatestAgentPromptCopied(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestAgentPromptCopied) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLatestAgentPromptCopied(false);
|
||||
}, 1600);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [latestAgentPromptCopied]);
|
||||
|
||||
// Fetch registered adapters from server (syncs disabled store + provides data)
|
||||
const { data: serverAdapters } = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: () => adaptersApi.list(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const nativeRunnerAvailable =
|
||||
serverAdapters?.some(
|
||||
(adapter) => adapter.type === "paperclip_runner" && !adapter.disabled,
|
||||
) === true;
|
||||
|
||||
// Fetch existing agents for the "Ask CEO" flow
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newAgentOpen,
|
||||
});
|
||||
|
||||
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
|
||||
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", 5);
|
||||
|
||||
// Build the adapter grid from the UI registry merged with display metadata.
|
||||
// This automatically includes external/plugin adapters.
|
||||
const adapterGrid = useMemo(() => {
|
||||
const registered = listUIAdapters()
|
||||
.filter((a) =>
|
||||
isAgentAdapterType(a.type) &&
|
||||
(a.type !== "paperclip_runner" || nativeRunnerAvailable) &&
|
||||
!disabledTypes.has(a.type) &&
|
||||
isVisualAdapterChoice(a.type)
|
||||
);
|
||||
|
||||
// Sort: recommended first, then alphabetical
|
||||
return registered
|
||||
.map((a) => {
|
||||
const display = getAdapterDisplay(a.type);
|
||||
return {
|
||||
value: a.type,
|
||||
label: display.label,
|
||||
desc: display.description,
|
||||
icon: display.icon,
|
||||
recommended: display.recommended,
|
||||
comingSoon: display.comingSoon,
|
||||
disabledLabel: display.disabledLabel,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.recommended && !b.recommended) return -1;
|
||||
if (!a.recommended && b.recommended) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [disabledTypes, nativeRunnerAvailable, serverAdapters]);
|
||||
|
||||
function handleAskCeo() {
|
||||
closeNewAgent();
|
||||
openNewIssue({
|
||||
assigneeAgentId: ceoAgent?.id,
|
||||
title: "Create a new agent",
|
||||
description: "(type in what kind of agent you want here)",
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdvancedConfig() {
|
||||
setMode("runtime");
|
||||
}
|
||||
|
||||
function handleInviteExternalAgent() {
|
||||
setMode("invite");
|
||||
}
|
||||
|
||||
function handleAdvancedAdapterPick(adapterType: string) {
|
||||
closeNewAgent();
|
||||
resetDialogState();
|
||||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||
}
|
||||
|
||||
async function copyText(text: string, unavailableBody: string) {
|
||||
try {
|
||||
await copyTextToClipboard(text);
|
||||
return true;
|
||||
} catch {
|
||||
pushToast({
|
||||
title: "Clipboard unavailable",
|
||||
body: unavailableBody,
|
||||
tone: "warn",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const createAgentInviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
agentMessage: agentMessage.trim() || null,
|
||||
}),
|
||||
onSuccess: async (invite) => {
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
const onboardingTextLink =
|
||||
invite.onboardingTextUrl ??
|
||||
invite.onboardingTextPath ??
|
||||
`/api/invites/${invite.token}/onboarding.txt`;
|
||||
const onboardingTextUrl = onboardingTextLink.startsWith("http")
|
||||
? onboardingTextLink
|
||||
: `${base}${onboardingTextLink}`;
|
||||
|
||||
let prompt: string;
|
||||
try {
|
||||
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
||||
prompt = buildAgentOnboardingPrompt({
|
||||
onboardingTextUrl,
|
||||
connectionCandidates:
|
||||
manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
||||
testResolutionUrl:
|
||||
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
|
||||
null,
|
||||
});
|
||||
} catch {
|
||||
prompt = buildAgentOnboardingPrompt({
|
||||
onboardingTextUrl,
|
||||
connectionCandidates: null,
|
||||
testResolutionUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
setLatestAgentPrompt(prompt);
|
||||
setLatestAgentPromptCopied(false);
|
||||
setMode("prompt");
|
||||
const copied = await copyText(prompt, "Copy the agent onboarding prompt manually from the field below.");
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
|
||||
pushToast({
|
||||
title: "Agent invite created",
|
||||
body: copied ? "Agent onboarding prompt ready below and copied to clipboard." : "Agent onboarding prompt ready below.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to create agent invite",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [invite, setInvite] = useState(false);
|
||||
if (invite && companyId) return <ExternalAgentInviteDialog companyId={companyId} onClose={onClose} onBack={() => setInvite(false)} />;
|
||||
return (
|
||||
<Dialog
|
||||
open={newAgentOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetDialogState();
|
||||
closeNewAgent();
|
||||
}
|
||||
<AgentBasicsDialog
|
||||
open
|
||||
onClose={onClose}
|
||||
onInvite={companyId ? () => setInvite(true) : undefined}
|
||||
onContinue={(basics) => {
|
||||
onClose();
|
||||
navigate(`/agents/new?${new URLSearchParams(basics)}`);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"max-h-(--sz-calc-16) p-0 gap-0 overflow-hidden flex flex-col",
|
||||
mode === "invite" || mode === "prompt" ? "sm:max-w-2xl" : "sm:max-w-md",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Add a new agent</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
resetDialogState();
|
||||
closeNewAgent();
|
||||
}}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto p-6 space-y-6">
|
||||
{mode === "choices" ? (
|
||||
<>
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Bot className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask a leader to propose the hire, configure a runtime yourself,
|
||||
or send an onboarding prompt to an external agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={handleAdvancedConfig}>
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
Configure a runtime manually
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<Button variant="outline" className="w-full" onClick={handleInviteExternalAgent}>
|
||||
<MailPlus className="h-4 w-4 mr-2" />
|
||||
Invite an external agent
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
(OpenClaw, Hermes, or any agent that can call the invite API.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : mode === "runtime" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMode("choices")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose the runtime Paperclip should start or resume directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{adapterGrid.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative",
|
||||
opt.comingSoon && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
disabled={!!opt.comingSoon}
|
||||
title={opt.comingSoon ? opt.disabledLabel : undefined}
|
||||
onClick={() => {
|
||||
if (!opt.comingSoon) handleAdvancedAdapterPick(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<Badge variant="ghost" className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-(length:--text-nano) font-semibold px-1.5 leading-none">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-(length:--text-nano)">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : mode === "invite" ? (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMode("choices")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Invite an external agent</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">Optional message for the agent</span>
|
||||
<Textarea
|
||||
value={agentMessage}
|
||||
onChange={(event) => setAgentMessage(event.target.value)}
|
||||
className="min-h-24 resize-y"
|
||||
placeholder="Add onboarding context, expected role, or first instructions."
|
||||
maxLength={4000}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Agent invites create a join request first. An organization admin still approves the request before the agent can claim its API key.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => createAgentInviteMutation.mutate()}
|
||||
disabled={!selectedCompanyId || createAgentInviteMutation.isPending}
|
||||
>
|
||||
{createAgentInviteMutation.isPending ? "Generating…" : "Generate onboarding prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMode("invite")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Agent onboarding prompt</h2>
|
||||
{latestAgentPromptCopied ? (
|
||||
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send this prompt to the external agent that should join this organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
readOnly
|
||||
value={latestAgentPrompt ?? ""}
|
||||
className="h-(--sz-24rem) resize-y font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!latestAgentPrompt}
|
||||
onClick={async () => {
|
||||
if (!latestAgentPrompt) return;
|
||||
const copied = await copyText(latestAgentPrompt, "Copy the agent onboarding prompt manually from the field above.");
|
||||
setLatestAgentPromptCopied(copied);
|
||||
}}
|
||||
>
|
||||
{latestAgentPromptCopied ? "Copied prompt" : "Copy prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
Play,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
export type TestState = "idle" | "running" | "pass" | "fail";
|
||||
|
||||
const copy = {
|
||||
idle: {
|
||||
title: "Test your agent",
|
||||
description: "Check that your runtime and model can respond.",
|
||||
action: "Run test",
|
||||
},
|
||||
running: {
|
||||
title: "Testing connection",
|
||||
description: "Checking the runtime and waiting for a model response…",
|
||||
action: "Testing…",
|
||||
},
|
||||
pass: {
|
||||
title: "Connection successful",
|
||||
description: "Your runtime checks passed. Review the details below.",
|
||||
action: "Test again",
|
||||
},
|
||||
warn: {
|
||||
title: "Connection needs attention",
|
||||
description: "Review the test details before running your agent.",
|
||||
action: "Test again",
|
||||
},
|
||||
fail: {
|
||||
title: "Couldn't connect",
|
||||
description: "Check your model and provider connection, then try again.",
|
||||
action: "Retry test",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function RuntimeTestCard({
|
||||
state,
|
||||
result,
|
||||
error,
|
||||
onTest,
|
||||
disabled = false,
|
||||
}: {
|
||||
state: TestState | "warn";
|
||||
result: AdapterEnvironmentTestResult | null;
|
||||
error?: string | null;
|
||||
onTest: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const content = copy[state];
|
||||
const Icon =
|
||||
state === "running"
|
||||
? Loader2
|
||||
: state === "pass"
|
||||
? CircleCheck
|
||||
: state === "fail" || state === "warn"
|
||||
? CircleAlert
|
||||
: Play;
|
||||
return (
|
||||
<section
|
||||
aria-label="Runtime test"
|
||||
className="rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 sm:items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full bg-muted",
|
||||
state === "pass"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: state === "fail"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4",
|
||||
state === "running" && "animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div
|
||||
role={state === "fail" ? "alert" : "status"}
|
||||
aria-atomic="true"
|
||||
className="min-w-0 space-y-1"
|
||||
>
|
||||
<h3 className="text-sm font-medium">{content.title}</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{state === "fail" && error ? error : content.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
disabled={disabled || state === "running"}
|
||||
onClick={onTest}
|
||||
>
|
||||
{content.action}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{result && result.checks.length > 0 && (
|
||||
<details
|
||||
key={`${state}:${result.testedAt}`}
|
||||
className="group border-t border-border"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2.5 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-3 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
Test details
|
||||
</summary>
|
||||
<ul className="space-y-3 px-4 pb-4">
|
||||
{result.checks.map((check) => (
|
||||
<li
|
||||
key={check.code}
|
||||
className="flex gap-2 text-xs leading-relaxed"
|
||||
>
|
||||
{check.level === "error" || check.level === "warn" ? (
|
||||
<CircleAlert
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-destructive"
|
||||
/>
|
||||
) : (
|
||||
<CircleCheck
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="break-words text-foreground">{check.message}</p>
|
||||
{check.detail && (
|
||||
<p className="break-all text-muted-foreground">
|
||||
{check.detail}
|
||||
</p>
|
||||
)}
|
||||
{check.hint && (
|
||||
<p className="text-muted-foreground">{check.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -135,6 +135,8 @@ export interface EnvironmentVariablesEditorProps {
|
|||
footerHint?: ReactNode | null;
|
||||
/** Reports editor-local draft changes that are not yet promoted to the parent value. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Parent owns Save/Discard and flushes the editor through its ref. */
|
||||
hideDraftActions?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentVariablesEditorHandle {
|
||||
|
|
@ -156,6 +158,7 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
|
|||
reservedPrefixes = DEFAULT_RESERVED_PREFIXES,
|
||||
footerHint,
|
||||
onDirtyChange,
|
||||
hideDraftActions = false,
|
||||
}: EnvironmentVariablesEditorProps, ref) {
|
||||
const toast = useOptionalToastActions();
|
||||
const editorRootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -518,7 +521,7 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
|
|||
|
||||
</div>
|
||||
|
||||
{hasUnsavedChanges && !disabled ? (
|
||||
{hasUnsavedChanges && !disabled && !hideDraftActions ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mt-3 flex w-full flex-col gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-3 text-amber-950 shadow-sm dark:bg-amber-500/15 dark:text-amber-100 @[34rem]/env:flex-row @[34rem]/env:items-center @[34rem]/env:justify-between"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight } from "lucide-react";
|
||||
import { adaptersApi } from "@/api/adapters";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { PillGuy } from "../onboarding/PillGuy";
|
||||
|
||||
export type AgentBasics = {
|
||||
name: string;
|
||||
adapterType: string;
|
||||
runnerProvider: string;
|
||||
};
|
||||
const brandMarks: Record<string, { src: string; dark?: string }> = {
|
||||
claude_local: { src: "/brands/claude-color.svg" },
|
||||
codex_local: { src: "/brands/codex-color.svg" },
|
||||
gemini_local: { src: "/brands/adapters/gemini-color.svg" },
|
||||
kimi_local: {
|
||||
src: "/brands/adapters/kimi-color-light.svg",
|
||||
dark: "/brands/adapters/kimi-color.svg",
|
||||
},
|
||||
...Object.fromEntries(
|
||||
[
|
||||
["cursor", "cursor"],
|
||||
["cursor_cloud", "cursor"],
|
||||
["grok_local", "grok"],
|
||||
["hermes_local", "hermesagent"],
|
||||
["hermes_gateway", "hermesagent"],
|
||||
["pi_local", "pi"],
|
||||
].map(([type, icon]) => [
|
||||
type,
|
||||
{
|
||||
src: `/brands/adapters/${icon}.svg`,
|
||||
dark: `/brands/adapters/${icon}-dark.svg`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
export function AdapterMark({
|
||||
type,
|
||||
className = "size-6",
|
||||
}: {
|
||||
type: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = getAdapterDisplay(type).icon;
|
||||
const mark = brandMarks[type];
|
||||
if (!mark) return <Icon className={className} />;
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={mark.src}
|
||||
className={cn(
|
||||
"shrink-0 object-contain",
|
||||
mark.dark && "dark:hidden",
|
||||
className,
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
{mark.dark && (
|
||||
<img
|
||||
src={mark.dark}
|
||||
className={cn("hidden shrink-0 object-contain dark:block", className)}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function AgentBasicsDialog({
|
||||
open,
|
||||
onClose,
|
||||
onContinue,
|
||||
initialAdapter = "",
|
||||
onInvite,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onContinue: (basics: AgentBasics) => void;
|
||||
initialAdapter?: string;
|
||||
onInvite?: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const cloud = Boolean(useCloudInstance());
|
||||
const experimental = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: instanceSettingsApi.getExperimental,
|
||||
enabled: open,
|
||||
retry: false,
|
||||
});
|
||||
const [name, setName] = useState("");
|
||||
const [adapterType, setAdapterType] = useState(initialAdapter);
|
||||
const [runnerProvider, setRunnerProvider] = useState("codex");
|
||||
const [step, setStep] = useState<"name" | "adapter">("name");
|
||||
const {
|
||||
data: adapters,
|
||||
isPending,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: adaptersApi.list,
|
||||
enabled: open,
|
||||
});
|
||||
const choices = (adapters ?? []).filter(
|
||||
(adapter) =>
|
||||
adapter.loaded &&
|
||||
!adapter.disabled &&
|
||||
isNewAgentAdapterAllowed(adapter.type, {
|
||||
cloud,
|
||||
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
|
||||
}) &&
|
||||
!["process", "http"].includes(adapter.type) &&
|
||||
!getAdapterDisplay(adapter.type).comingSoon,
|
||||
);
|
||||
const validAdapter = choices.some((adapter) => adapter.type === adapterType);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-(--sz-640px)",
|
||||
step === "name" && "sm:max-w-(--sz-560px)",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-6 py-5 text-xs text-muted-foreground"
|
||||
aria-label="New agent progress"
|
||||
>
|
||||
<span
|
||||
className={cn(step === "name" && "font-medium text-foreground")}
|
||||
>
|
||||
1. Name
|
||||
</span>
|
||||
<ChevronRight className="size-3" />
|
||||
<span
|
||||
className={cn(step === "adapter" && "font-medium text-foreground")}
|
||||
>
|
||||
2. Adapter
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
className="flex min-h-0 flex-col"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
if (step === "name") setStep("adapter");
|
||||
else if (validAdapter)
|
||||
onContinue({ name: name.trim(), adapterType, runnerProvider });
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<PillGuy state="dormant" className="size-16" />
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-3xl font-semibold tracking-tight">
|
||||
{step === "name"
|
||||
? "Meet your next agent"
|
||||
: "Choose an adapter"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
{step === "name"
|
||||
? "Start with a name. Make them your own."
|
||||
: `How should ${name.trim()} work?`}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
{step === "name" ? (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
Agent name
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
maxLength={100}
|
||||
placeholder="e.g. Darnold"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{onInvite && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="px-0 text-muted-foreground"
|
||||
onClick={onInvite}
|
||||
>
|
||||
Invite an external agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="sr-only">Adapter</legend>
|
||||
{isPending && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Loading adapters…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{choices.map((adapter) => {
|
||||
const display = getAdapterDisplay(adapter.type);
|
||||
return (
|
||||
<label
|
||||
key={adapter.type}
|
||||
className="relative cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="new-agent-adapter"
|
||||
value={adapter.type}
|
||||
checked={adapterType === adapter.type}
|
||||
onChange={() => setAdapterType(adapter.type)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center gap-2 rounded-lg border px-3 py-4 text-center peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent/40",
|
||||
adapterType === adapter.type
|
||||
? "border-foreground/40 bg-accent"
|
||||
: "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<AdapterMark type={adapter.type} />
|
||||
<span className="text-sm font-medium">
|
||||
{display.label}
|
||||
</span>
|
||||
{adapterType === adapter.type && (
|
||||
<Check className="absolute right-2 top-2 size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{validAdapter && adapterType === "paperclip_runner" && (
|
||||
<label className="flex flex-col gap-2 text-sm font-medium">
|
||||
Runner
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-3 py-2"
|
||||
value={runnerProvider}
|
||||
onChange={(event) =>
|
||||
setRunnerProvider(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="codex">Codex (app server)</option>
|
||||
<option value="claude">Claude (ACPX)</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-t border-border px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => (step === "name" ? onClose() : setStep("name"))}
|
||||
>
|
||||
{step === "name" ? (
|
||||
"Cancel"
|
||||
) : (
|
||||
<>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim() || (step === "adapter" && !validAdapter)}
|
||||
>
|
||||
{step === "name" ? "Choose adapter" : "Configure agent"}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AdapterLoginPanel } from "../AgentConfigForm";
|
||||
import {
|
||||
OnboardingCardField,
|
||||
OnboardingLoginCard,
|
||||
} from "../AdapterLoginChrome";
|
||||
import { ModelSourceTiles } from "../onboarding/ModelSourceTiles";
|
||||
import { CredentialModeLink } from "../onboarding/CredentialModeLink";
|
||||
import { FooterNav } from "../onboarding/FooterNav";
|
||||
import { MAKE_ROOM, CARD_ENTER } from "../onboarding/onboarding-motion";
|
||||
import { buildFixedClaudeOAuthBinding } from "../environment-variables-editor/model";
|
||||
import type { EnvBinding } from "@paperclipai/shared";
|
||||
|
||||
export type ProviderConnection = {
|
||||
env: Record<string, EnvBinding>;
|
||||
/** Kept in memory until the user finishes setup. */
|
||||
credentials?: Record<string, string>;
|
||||
storedSessionId?: string;
|
||||
applyStoredClaudeLogin?: boolean;
|
||||
};
|
||||
export function AgentProviderConnection({
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
canLogin,
|
||||
onConnected,
|
||||
onBack,
|
||||
testConnection,
|
||||
testError,
|
||||
}: {
|
||||
companyId: string;
|
||||
adapterType: "claude_local" | "codex_local";
|
||||
environmentId: string | null;
|
||||
canLogin: boolean;
|
||||
onConnected: (connection: ProviderConnection) => void;
|
||||
onBack: () => void;
|
||||
testConnection: (connection: ProviderConnection) => Promise<boolean>;
|
||||
testError?: string | null;
|
||||
}) {
|
||||
const epoch = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
epoch.current++;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const cancel = () => {
|
||||
epoch.current++;
|
||||
setBusy(false);
|
||||
setOpened(false);
|
||||
};
|
||||
const [method, setMethod] = useState<"subscription" | "api">("subscription");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [storedConnection, setStoredConnection] =
|
||||
useState<ProviderConnection | null>(null);
|
||||
const provider = adapterType === "claude_local" ? "Claude" : "OpenAI";
|
||||
const envKey =
|
||||
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
||||
const auth = useQuery({
|
||||
queryKey: queryKeys.agents.authSignal(
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
),
|
||||
queryFn: () =>
|
||||
agentsApi.getAdapterAuthSignal(
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId ?? undefined,
|
||||
),
|
||||
retry: false,
|
||||
});
|
||||
async function connect() {
|
||||
if (busy) return;
|
||||
const run = ++epoch.current;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const connection =
|
||||
method === "api"
|
||||
? (storedConnection ?? {
|
||||
env: {},
|
||||
credentials: { [envKey]: apiKey.trim() },
|
||||
})
|
||||
: {
|
||||
env: {},
|
||||
...(adapterType === "claude_local" &&
|
||||
canLogin &&
|
||||
auth.data?.status === "present"
|
||||
? {
|
||||
env: buildFixedClaudeOAuthBinding(),
|
||||
applyStoredClaudeLogin: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (run !== epoch.current) return;
|
||||
if (method === "api") {
|
||||
setApiKey("");
|
||||
setStoredConnection(connection);
|
||||
}
|
||||
const connected = await testConnection(connection);
|
||||
if (run !== epoch.current) return;
|
||||
if (connected) onConnected(connection);
|
||||
else
|
||||
setError(
|
||||
"The provider did not respond. Check the connection and try again.",
|
||||
);
|
||||
} catch (cause) {
|
||||
if (run !== epoch.current) return;
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Could not connect to the provider.",
|
||||
);
|
||||
} finally {
|
||||
if (run === epoch.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
const needsLogin =
|
||||
method === "subscription" &&
|
||||
canLogin &&
|
||||
environmentId &&
|
||||
auth.data?.status !== "present";
|
||||
return (
|
||||
<div>
|
||||
<ModelSourceTiles
|
||||
label="Connect your model provider"
|
||||
sources={[
|
||||
{
|
||||
id: adapterType,
|
||||
label: provider,
|
||||
icon: (
|
||||
<img
|
||||
src={`/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
|
||||
className="size-6"
|
||||
alt=""
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
mode={method}
|
||||
selectedId={opened ? adapterType : null}
|
||||
collapsed={opened}
|
||||
onSelect={() => setOpened(true)}
|
||||
/>
|
||||
{!opened && (
|
||||
<div className="-ml-3 mt-1">
|
||||
<CredentialModeLink
|
||||
mode={method}
|
||||
onChange={(next) => {
|
||||
setMethod(next);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: opened ? "auto" : 0, opacity: opened ? 1 : 0 }}
|
||||
transition={{ height: MAKE_ROOM, opacity: CARD_ENTER }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{opened && (
|
||||
<div className="pt-5">
|
||||
{method === "api" ? (
|
||||
<OnboardingLoginCard
|
||||
instruction={`Provide your ${provider} API key to connect`}
|
||||
>
|
||||
<OnboardingCardField
|
||||
label="API key"
|
||||
masked
|
||||
autoFocus
|
||||
value={apiKey}
|
||||
placeholder={
|
||||
storedConnection
|
||||
? "Key entered. Retry the connection."
|
||||
: "Enter API key here"
|
||||
}
|
||||
onChange={(value) => {
|
||||
setApiKey(value);
|
||||
setStoredConnection(null);
|
||||
}}
|
||||
onSubmit={() => void connect()}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OnboardingLoginCard>
|
||||
) : needsLogin ? (
|
||||
<AdapterLoginPanel
|
||||
companyId={companyId}
|
||||
adapterType={adapterType}
|
||||
environmentId={environmentId}
|
||||
chrome="onboarding"
|
||||
autoStart
|
||||
onStored={(storedSessionId) => {
|
||||
const connection = {
|
||||
env: buildFixedClaudeOAuthBinding(),
|
||||
storedSessionId,
|
||||
};
|
||||
setStoredConnection(connection);
|
||||
onConnected(connection);
|
||||
}}
|
||||
onConnected={() => {
|
||||
if (adapterType === "codex_local") onConnected({ env: {} });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canLogin
|
||||
? "Use the subscription already connected to this environment."
|
||||
: `Use the ${provider} login on this machine. If you haven’t signed in yet, run ${adapterType === "claude_local" ? "claude auth login" : "codex login"} in your terminal, then connect.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm text-destructive">
|
||||
{testError ?? error}
|
||||
</p>
|
||||
)}
|
||||
<FooterNav
|
||||
onBack={() => {
|
||||
if (opened) cancel();
|
||||
else onBack();
|
||||
}}
|
||||
primaryLabel={busy ? "Connecting" : "Connect"}
|
||||
primaryDisabled={
|
||||
auth.isPending ||
|
||||
!opened ||
|
||||
Boolean(needsLogin) ||
|
||||
(method === "api" && !apiKey.trim() && !storedConnection)
|
||||
}
|
||||
loading={busy}
|
||||
onPrimary={() => void connect()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { Button } from "../ui/button";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
|
||||
|
||||
/** Preserve the existing agent-only invitation path beside the setup wizard. */
|
||||
export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
|
||||
companyId: string;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const cache = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; };
|
||||
}, []);
|
||||
const [message, setMessage] = useState("");
|
||||
const [prompt, setPrompt] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await copyTextToClipboard(value);
|
||||
if (mounted.current) { setCopied(true); setCopyError(false); }
|
||||
} catch {
|
||||
if (mounted.current) setCopyError(true);
|
||||
}
|
||||
}
|
||||
const createInvite = useMutation({
|
||||
mutationFn: async () => {
|
||||
const invite = await accessApi.createCompanyInvite(companyId, {
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
agentMessage: message.trim() || null,
|
||||
});
|
||||
void cache.invalidateQueries({ queryKey: queryKeys.access.invites(companyId, "all", 5) });
|
||||
const path = invite.onboardingTextUrl ?? invite.onboardingTextPath ?? `/api/invites/${invite.token}/onboarding.txt`;
|
||||
const onboardingTextUrl = new URL(path, window.location.origin).href;
|
||||
const manifest = await accessApi.getInviteOnboarding(invite.token).catch(() => null);
|
||||
return buildAgentOnboardingPrompt({
|
||||
onboardingTextUrl,
|
||||
connectionCandidates: manifest?.onboarding.connectivity?.connectionCandidates ?? null,
|
||||
testResolutionUrl: manifest?.onboarding.connectivity?.testResolutionEndpoint?.url ?? null,
|
||||
});
|
||||
},
|
||||
onSuccess: async (value) => {
|
||||
if (!mounted.current) return;
|
||||
setPrompt(value);
|
||||
await copy(value);
|
||||
},
|
||||
});
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-h-(--sz-calc-16) overflow-y-auto sm:max-w-2xl">
|
||||
<DialogTitle>{prompt ? "Agent onboarding prompt" : "Invite an external agent"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{prompt ? "Send this one-time prompt to the agent that should join your organization."
|
||||
: "Generate a one-time onboarding prompt for an external agent. An organization admin must approve its join request before it can claim an API key."}
|
||||
</DialogDescription>
|
||||
{prompt ? <>
|
||||
<Textarea aria-label="Agent onboarding prompt" readOnly value={prompt} className="min-h-64 font-mono text-xs" />
|
||||
{copyError && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above.</p>}
|
||||
<div className="flex justify-between gap-4">
|
||||
<Button variant="ghost" onClick={onClose}>Done</Button>
|
||||
<Button variant="outline" onClick={() => void copy(prompt)}>{copied ? "Copied prompt" : "Copy prompt"}</Button>
|
||||
</div>
|
||||
</> : <>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span>Optional message for the agent</span>
|
||||
<Textarea value={message} onChange={(event) => setMessage(event.target.value)} maxLength={4000} className="min-h-24" />
|
||||
</label>
|
||||
{createInvite.error && <p role="alert" className="text-sm text-destructive">{createInvite.error.message}</p>}
|
||||
<div className="flex justify-between gap-4">
|
||||
<Button variant="ghost" onClick={onBack}>Back</Button>
|
||||
<Button disabled={createInvite.isPending} onClick={() => createInvite.mutate()}>
|
||||
{createInvite.isPending ? "Generating…" : "Generate onboarding prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
|
@ -2779,3 +2779,60 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
* allow ui/src/pages/Projects.test.tsx — mock project color fixture
|
||||
* allow ui/src/pages/Routines.test.tsx — mock routine/label color fixtures
|
||||
── end allowlist ── */
|
||||
|
||||
/* Agent settings composition, shared with Storybook. */
|
||||
.agent-settings-form > .relative {
|
||||
@apply flex flex-col gap-8;
|
||||
}
|
||||
.agent-settings-form [data-config-section] {
|
||||
@apply my-0;
|
||||
}
|
||||
.agent-settings-form [data-config-section] > .rounded-lg {
|
||||
@apply bg-card p-5;
|
||||
}
|
||||
.agent-settings-form [data-config-section] h3 {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.agent-settings-form [data-config-section="identity"] > .rounded-lg {
|
||||
@apply grid gap-5 sm:grid-cols-2;
|
||||
}
|
||||
.agent-settings-form [data-config-section="identity"] > .rounded-lg > * {
|
||||
@apply m-0;
|
||||
}
|
||||
.agent-settings-form
|
||||
[data-config-section="identity"]
|
||||
> .rounded-lg
|
||||
> :nth-child(n + 3) {
|
||||
@apply sm:col-span-2;
|
||||
}
|
||||
.agent-settings-overview section,
|
||||
.agent-settings-permissions .rounded-lg,
|
||||
.agent-settings-api-keys .rounded-lg,
|
||||
.agent-settings-revisions .rounded-md {
|
||||
@apply bg-card;
|
||||
}
|
||||
.agent-settings-content > .max-w-3xl {
|
||||
@apply max-w-none;
|
||||
}
|
||||
|
||||
.agent-settings-form [data-config-section="permissions"] > .rounded-lg {
|
||||
@apply grid items-start gap-5 sm:grid-cols-2;
|
||||
}
|
||||
.agent-settings-form [data-config-section="permissions"] > .rounded-lg > * {
|
||||
@apply m-0 min-w-0;
|
||||
}
|
||||
.agent-settings-form
|
||||
[data-config-section="permissions"]
|
||||
> .rounded-lg
|
||||
> [data-config-field],
|
||||
.agent-settings-form [data-config-section="permissions"] > .rounded-lg > p,
|
||||
.agent-settings-form
|
||||
[data-config-section="permissions"]
|
||||
> .rounded-lg
|
||||
> div:has(textarea),
|
||||
.agent-settings-form
|
||||
[data-config-section="permissions"]
|
||||
> .rounded-lg
|
||||
> div:has([role="switch"]) {
|
||||
@apply sm:col-span-2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
||||
import { codexReasoningEffortOptions } from "./codex-reasoning-effort";
|
||||
import { PROVIDER_ENV_KEYS } from "./provider-credential";
|
||||
|
||||
/** Only controls consumed by each adapter's config builder and runtime belong here. */
|
||||
export const SETUP_CREDENTIAL_KEYS: Record<string, string> = {
|
||||
cursor: "CURSOR_API_KEY",
|
||||
cursor_cloud: "CURSOR_API_KEY",
|
||||
gemini_local: "GEMINI_API_KEY",
|
||||
kimi_local: "KIMI_MODEL_API_KEY",
|
||||
hermes_gateway: "API_SERVER_KEY",
|
||||
};
|
||||
|
||||
export const HERMES_PROVIDER_KEYS: Record<string, string> = {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
"kimi-coding": "KIMI_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
};
|
||||
|
||||
export function setupProviderKeys(adapter: string) {
|
||||
return adapter === "hermes_local" ? HERMES_PROVIDER_KEYS : PROVIDER_ENV_KEYS;
|
||||
}
|
||||
|
||||
export function setupEfforts(adapter: string, model = ""): string[] {
|
||||
switch (adapter) {
|
||||
case "claude_local":
|
||||
return ["low", "medium", "high"];
|
||||
case "codex_local":
|
||||
return codexReasoningEffortOptions(model || DEFAULT_CODEX_LOCAL_MODEL)
|
||||
.map((option) => option.value)
|
||||
.filter(Boolean);
|
||||
case "pi_local":
|
||||
return ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
case "grok_local":
|
||||
return ["low", "medium", "high"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const SETUP_LOGIN_HINTS: Record<string, string> = {
|
||||
cursor:
|
||||
"Use a Cursor API key, or run agent login on the selected environment's host.",
|
||||
gemini_local:
|
||||
"Use a Gemini API key, or an existing supported Gemini CLI login on the selected environment's host.",
|
||||
kimi_local:
|
||||
"Use a Kimi API key and model settings below, or run kimi login on the selected environment's host.",
|
||||
grok_local:
|
||||
"Grok Build uses its CLI sign-in. Run grok login on the selected environment's host, then test the connection here.",
|
||||
hermes_local:
|
||||
"Use a provider API key, or the existing Hermes provider configuration on the selected environment's host.",
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
const CLOUD_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
]);
|
||||
|
||||
/** Creation policy shared by the picker and direct setup links. */
|
||||
export function isNewAgentAdapterAllowed(
|
||||
type: string,
|
||||
{
|
||||
cloud,
|
||||
nativeRunnerEnabled,
|
||||
}: { cloud: boolean; nativeRunnerEnabled: boolean },
|
||||
) {
|
||||
if (cloud) return CLOUD_ADAPTERS.has(type);
|
||||
return type !== "paperclip_runner" || nativeRunnerEnabled;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { secretsApi } from "../api/secrets";
|
||||
|
||||
export const PROVIDER_ENV_KEYS: Record<string, string> = {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
};
|
||||
|
||||
/** New organization credentials get a distinct key; never rotate another agent's secret. */
|
||||
export async function storeOrganizationApiKey(
|
||||
companyId: string,
|
||||
envKey: string,
|
||||
value: string,
|
||||
) {
|
||||
const secret = await secretsApi.create(companyId, {
|
||||
name: `${envKey} · agent setup`,
|
||||
key: `${envKey}.setup.${crypto.randomUUID()}`,
|
||||
value: value.trim(),
|
||||
description: "Adapter credential supplied during agent setup.",
|
||||
});
|
||||
return {
|
||||
binding: {
|
||||
type: "secret_ref" as const,
|
||||
secretId: secret.id,
|
||||
version: "latest" as const,
|
||||
},
|
||||
remove: () => secretsApi.remove(secret.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Store a validated key during agent creation without rotating other agents’
|
||||
* credentials. The caller removes this definition if creation fails. */
|
||||
export async function storeProviderApiKey(
|
||||
companyId: string,
|
||||
envKey: string,
|
||||
value: string,
|
||||
) {
|
||||
const key = `${envKey}.setup.${crypto.randomUUID()}`;
|
||||
const definition = await secretsApi.createUserSecretDefinition(companyId, {
|
||||
key,
|
||||
name: `${envKey} · agent setup`,
|
||||
description: "Model provider credential for a new agent setup.",
|
||||
});
|
||||
const remove = () =>
|
||||
secretsApi.removeUserSecretDefinition(companyId, definition.id);
|
||||
try {
|
||||
await secretsApi.createMyUserSecret(companyId, {
|
||||
definitionId: definition.id,
|
||||
definitionKey: key,
|
||||
value: value.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
await remove();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
binding: {
|
||||
type: "user_secret_ref" as const,
|
||||
key,
|
||||
version: "latest" as const,
|
||||
},
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { testAgentSetup } from "./test-agent-setup";
|
||||
const testEnvironment = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../api/agents", () => ({ agentsApi: { testEnvironment } }));
|
||||
const input = {
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
providerAdapter: "claude_local",
|
||||
environmentId: "sandbox-1",
|
||||
adapterConfig: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: {
|
||||
type: "user_secret_ref",
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ready = {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "pass",
|
||||
testedAt: "now",
|
||||
checks: [{ code: "runtime", level: "info", message: "Ready" }],
|
||||
};
|
||||
beforeEach(() => testEnvironment.mockReset());
|
||||
it("does not report a connection when runtime readiness passes but provider authentication fails", async () => {
|
||||
testEnvironment
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({
|
||||
...ready,
|
||||
adapterType: "claude_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "claude_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Invalid API key",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await testAgentSetup(input);
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.adapterType).toBe("paperclip_runner");
|
||||
expect(testEnvironment).toHaveBeenLastCalledWith(
|
||||
"company-1",
|
||||
"claude_local",
|
||||
{
|
||||
environmentId: "sandbox-1",
|
||||
adapterConfig: { ...input.adapterConfig, engine: "cli" },
|
||||
},
|
||||
);
|
||||
expect(result.checks.map((check) => check.code)).toEqual([
|
||||
"runtime",
|
||||
"claude_hello_probe_failed",
|
||||
]);
|
||||
});
|
||||
it("does not repeat a completed model probe", async () => {
|
||||
testEnvironment.mockResolvedValue({
|
||||
...ready,
|
||||
checks: [
|
||||
{ code: "codex_hello_probe_passed", level: "info", message: "Hello" },
|
||||
],
|
||||
});
|
||||
await testAgentSetup({
|
||||
...input,
|
||||
adapterType: "codex_local",
|
||||
providerAdapter: "codex_local",
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("preserves runtime warnings after a successful provider request", async () => {
|
||||
testEnvironment
|
||||
.mockResolvedValueOnce({ ...ready, status: "warn" })
|
||||
.mockResolvedValueOnce(ready);
|
||||
expect((await testAgentSetup(input)).status).toBe("warn");
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
||||
/** ACP readiness checks do not authenticate a provider. Verify credentials with
|
||||
* the adapter's existing read-only CLI hello probe before calling setup connected. */
|
||||
export async function testAgentSetup(input: {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
providerAdapter: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId: string | null;
|
||||
}): Promise<AdapterEnvironmentTestResult> {
|
||||
const payload = {
|
||||
adapterConfig: input.adapterConfig,
|
||||
...(input.testCredentials ? { testCredentials: input.testCredentials } : {}),
|
||||
environmentId: input.environmentId,
|
||||
};
|
||||
const runtime = await agentsApi.testEnvironment(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
payload,
|
||||
);
|
||||
if (
|
||||
runtime.status === "fail" ||
|
||||
runtime.checks.some(
|
||||
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
) ||
|
||||
runtime.checks.some((check) => check.code.includes("hello_probe")) ||
|
||||
!["claude_local", "codex_local"].includes(input.providerAdapter)
|
||||
)
|
||||
return runtime;
|
||||
const provider = await agentsApi.testEnvironment(
|
||||
input.companyId,
|
||||
input.providerAdapter,
|
||||
{
|
||||
...payload,
|
||||
adapterConfig: { ...input.adapterConfig, engine: "cli" },
|
||||
},
|
||||
);
|
||||
const checks = [
|
||||
...new Map(
|
||||
[...runtime.checks, ...provider.checks].map((check) => [
|
||||
check.code,
|
||||
check,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
return {
|
||||
adapterType: input.adapterType,
|
||||
testedAt: provider.testedAt,
|
||||
status:
|
||||
provider.status === "fail"
|
||||
? "fail"
|
||||
: runtime.status === "warn" || provider.status === "warn"
|
||||
? "warn"
|
||||
: "pass",
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
|
@ -25,10 +25,11 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { PillGuy } from "../components/onboarding/PillGuy";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
|
|
@ -760,7 +761,6 @@ export function AgentDetail() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const activeView: AgentDetailView = urlRunId ? "run-detail" : parseAgentDetailView(urlTab ?? null);
|
||||
|
|
@ -959,19 +959,13 @@ export function AgentDetail() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (!streamlinedUiEnabled) {
|
||||
if (routeAgentRef !== canonicalAgentRef) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${urlTab ?? "dashboard"}`, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (legacyAuditSection) return;
|
||||
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, streamlinedUiEnabled]);
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||
|
|
@ -1149,18 +1143,18 @@ export function AgentDetail() {
|
|||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
if (streamlinedUiEnabled && !urlRunId && legacyAuditSection) {
|
||||
if (!urlRunId && legacyAuditSection) {
|
||||
return <Navigate to={agentScopedAuditHref(agent.id, legacyAuditSection)} replace />;
|
||||
}
|
||||
if (!urlRunId && !urlTab) {
|
||||
return <Navigate to={streamlinedUiEnabled ? agentDetailHref(canonicalAgentRef) : `/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||
return <Navigate to={agentDetailHref(canonicalAgentRef)} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||
const pausedEscalationWarning = !hasInvalidOrgChain ? agent.orgChainHealth?.escalationWarning ?? null : null;
|
||||
const showConfigActionBar = (
|
||||
activeView === "runtime" || activeView === "instructions" || activeView === "secrets"
|
||||
) && (configDirty || configSaving);
|
||||
);
|
||||
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
|
||||
const agentMembershipPending =
|
||||
membershipMutation.isPending &&
|
||||
|
|
@ -1171,7 +1165,7 @@ export function AgentDetail() {
|
|||
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||
<div className={cn("agent-settings-content mx-auto max-w-5xl space-y-8", `agent-settings-${activeView}`)}>
|
||||
{showLeftAgentNotice ? (
|
||||
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-900 dark:text-yellow-100">
|
||||
<p className="min-w-0 flex-1">
|
||||
|
|
@ -1237,27 +1231,23 @@ export function AgentDetail() {
|
|||
</div>
|
||||
) : null}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<AgentIconPicker
|
||||
value={agent.icon}
|
||||
onChange={(icon) => updateIcon.mutate(icon)}
|
||||
>
|
||||
<button className="shrink-0 flex items-center justify-center h-12 w-12 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
|
||||
<AgentIcon icon={agent.icon} className="h-6 w-6" />
|
||||
</button>
|
||||
</AgentIconPicker>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
|
||||
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0">
|
||||
<PillGuy state="alive" className="size-12" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight">{agent.name}</h1>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{agent.adapterType === "claude_local" || agent.adapterType === "codex_local"
|
||||
? <img src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`} className="size-4" alt="" />
|
||||
: null}
|
||||
<span>{getAdapterDisplay(agent.adapterType).label}</span><span>·</span>
|
||||
<span>{agent.title || roleLabels[agent.role] || agent.role}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{roleLabels[agent.role] ?? agent.role}
|
||||
{agent.title ? ` - ${agent.title}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StarToggle
|
||||
size="button"
|
||||
starred={agentStarred}
|
||||
|
|
@ -1274,7 +1264,8 @@ export function AgentDetail() {
|
|||
agent={agent}
|
||||
companyId={resolvedCompanyId}
|
||||
assignLabel="Assign Task"
|
||||
runLabel="Run Heartbeat"
|
||||
showRun={false}
|
||||
showStatus={false}
|
||||
canRunWithProviderTrace={canUseProviderTrace}
|
||||
actionsDisabled={agentAction.isPending}
|
||||
workActionsDisabled={hasInvalidOrgChain}
|
||||
|
|
@ -1313,7 +1304,7 @@ export function AgentDetail() {
|
|||
)}
|
||||
</AgentActionButtons>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{builtInState && (
|
||||
<InlineBanner
|
||||
|
|
@ -1363,15 +1354,6 @@ export function AgentDetail() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!streamlinedUiEnabled && !urlRunId ? (
|
||||
<Tabs value={legacyView} onValueChange={handleLegacyTabChange}>
|
||||
<PageTabBar
|
||||
items={LEGACY_AGENT_DETAIL_TABS}
|
||||
value={legacyView}
|
||||
onValueChange={handleLegacyTabChange}
|
||||
/>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
|
|
@ -1389,57 +1371,10 @@ export function AgentDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Save/Cancel (desktop) */}
|
||||
{!isMobile && showConfigActionBar && (
|
||||
<div className="fixed bottom-6 right-6 z-30">
|
||||
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => cancelConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom Save/Cancel bar */}
|
||||
{isMobile && showConfigActionBar && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur-sm">
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-3 py-2"
|
||||
style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => cancelConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeView !== "run-detail" && <h2 className="text-xl font-semibold">{activeView === "secrets" ? "Secrets & variables" : AGENT_DETAIL_NAVIGATION.flatMap(section => section.items).find(item => item.value === activeView)?.label}</h2>}
|
||||
|
||||
{/* View content */}
|
||||
{activeView === "overview" && (streamlinedUiEnabled || !legacyAuditSection) && (
|
||||
{activeView === "overview" && (
|
||||
<AgentOverview
|
||||
agent={agent}
|
||||
runs={heartbeats ?? []}
|
||||
|
|
@ -1456,6 +1391,7 @@ export function AgentDetail() {
|
|||
<PromptsTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
showSaveNotice={false}
|
||||
onDirtyChange={setConfigDirty}
|
||||
onSaveActionChange={setSaveConfigAction}
|
||||
onCancelActionChange={setCancelConfigAction}
|
||||
|
|
@ -1464,7 +1400,7 @@ export function AgentDetail() {
|
|||
)}
|
||||
|
||||
{activeView === "runtime" && (
|
||||
<div className="max-w-3xl">
|
||||
<div>
|
||||
<ConfigurationTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
|
|
@ -1482,7 +1418,7 @@ export function AgentDetail() {
|
|||
)}
|
||||
|
||||
{activeView === "secrets" && (
|
||||
<div className="max-w-3xl">
|
||||
<div>
|
||||
<ConfigurationTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
|
|
@ -1508,7 +1444,7 @@ export function AgentDetail() {
|
|||
)}
|
||||
|
||||
{activeView === "permissions" && (
|
||||
<div className="max-w-3xl">
|
||||
<div>
|
||||
<ConfigurationTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
|
|
@ -1544,33 +1480,14 @@ export function AgentDetail() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!streamlinedUiEnabled && legacyAuditSection === "runs" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
companyId={resolvedCompanyId!}
|
||||
agentId={agent.id}
|
||||
agentRouteId={canonicalAgentRef}
|
||||
selectedRunId={null}
|
||||
adapterType={agent.adapterType}
|
||||
adapterConfig={agent.adapterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!streamlinedUiEnabled && legacyAuditSection === "activity" && resolvedCompanyId ? (
|
||||
<AuditFeed companyId={resolvedCompanyId} lockedAgentId={agent.id} />
|
||||
) : null}
|
||||
|
||||
{!streamlinedUiEnabled && (legacyAuditSection === "costs" || legacyAuditSection === "budgets") ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">Agent budget</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review this agent's budget policy and spend in the organization costs view.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/costs">Open costs and budgets</Link>
|
||||
</Button>
|
||||
{showConfigActionBar && <footer className="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
|
||||
<p role="status" className="text-xs text-muted-foreground">{configSaving ? "Saving changes…" : configDirty ? "You have unsaved changes." : ""}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={!configDirty || configSaving} onClick={() => cancelConfigActionRef.current?.()}>Discard</Button>
|
||||
<Button disabled={!configDirty || configSaving} onClick={() => {Promise.resolve(saveConfigActionRef.current?.()).catch(() => {});}}>{configSaving ? "Saving…" : "Save changes"}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</footer>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1936,7 +1853,7 @@ export function syncAgentRouteAfterRename(
|
|||
return true;
|
||||
}
|
||||
|
||||
function AgentRevisionsTab({
|
||||
export function AgentRevisionsTab({
|
||||
agent,
|
||||
companyId,
|
||||
}: {
|
||||
|
|
@ -2004,7 +1921,7 @@ function AgentRevisionsTab({
|
|||
|
||||
/* ---- Configuration Tab ---- */
|
||||
|
||||
function ConfigurationTab({
|
||||
export function ConfigurationTab({
|
||||
agent,
|
||||
companyId,
|
||||
onDirtyChange,
|
||||
|
|
@ -2123,7 +2040,7 @@ function ConfigurationTab({
|
|||
: "Disabled unless explicitly granted.";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="agent-settings-form space-y-6">
|
||||
{content !== "permissions" ? <AgentConfigForm
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
|
|
@ -2138,13 +2055,13 @@ function ConfigurationTab({
|
|||
hideInstructionsFile={hideInstructionsFile}
|
||||
content={content === "runtime" ? "configuration" : "secrets"}
|
||||
sectionLayout="cards"
|
||||
environmentVariablesPlacement="secrets"
|
||||
compactTestFeedback
|
||||
sectionOrder={["adapter", "permissions", "environment", "run-policy", "identity"]}
|
||||
sectionTitles={{ adapter: "Harness", permissions: "Model & execution", identity: "Agent identity" }}
|
||||
canConfigureProviderTrace={canConfigureProviderTrace}
|
||||
/> : null}
|
||||
{content === "runtime" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
{content === "permissions" ? <TrustPresetSection
|
||||
permissions={agent.permissions}
|
||||
|
|
@ -2244,7 +2161,9 @@ export function PromptsTab({
|
|||
onSaveActionChange,
|
||||
onCancelActionChange,
|
||||
onSavingChange,
|
||||
showSaveNotice = true,
|
||||
}: {
|
||||
showSaveNotice?: boolean;
|
||||
agent: Agent;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
|
|
@ -2624,9 +2543,9 @@ export function PromptsTab({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{showSaveNotice && <p className="text-xs text-muted-foreground">
|
||||
Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session.
|
||||
</p>
|
||||
</p>}
|
||||
|
||||
<Collapsible defaultOpen={currentMode === "external"}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
||||
|
|
@ -4480,7 +4399,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
|
||||
/* ---- Keys Tab ---- */
|
||||
|
||||
function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
|
||||
export function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
|
|
|
|||
|
|
@ -468,24 +468,26 @@ describe("Agents", () => {
|
|||
expect(row).not.toBeNull();
|
||||
expect(row?.querySelector(".sm\\:hidden")).toBeNull();
|
||||
expect(row?.querySelector(".hidden.sm\\:flex")).not.toBeNull();
|
||||
expect(row?.querySelector(".flex-1.hidden.xl\\:block")).not.toBeNull();
|
||||
expect(row?.querySelector(".flex-1.hidden.\\@5xl\\:block")).not.toBeNull();
|
||||
expect(row?.classList.contains("text-foreground/55")).toBe(false);
|
||||
expect(row?.classList.contains("sm:text-foreground/55")).toBe(true);
|
||||
const name = row?.querySelector("span[title='Paperclip Engineer With A Much Longer Display Name']");
|
||||
const subtitle = Array.from(row?.querySelectorAll("p") ?? []).find((node) =>
|
||||
node.textContent?.includes("Software Engineer With A Much Longer Specialty Title"),
|
||||
);
|
||||
expect(name?.classList.contains("whitespace-normal")).toBe(true);
|
||||
expect(name?.classList.contains("break-words")).toBe(true);
|
||||
expect(name?.classList.contains("xl:truncate")).toBe(true);
|
||||
expect(name?.classList.contains("xl:whitespace-nowrap")).toBe(true);
|
||||
expect(name?.classList.contains("truncate")).toBe(false);
|
||||
expect(name?.classList.contains("truncate")).toBe(true);
|
||||
expect(subtitle).toBeDefined();
|
||||
expect(subtitle?.classList.contains("whitespace-normal")).toBe(true);
|
||||
expect(subtitle?.classList.contains("break-words")).toBe(true);
|
||||
expect(subtitle?.classList.contains("xl:truncate")).toBe(true);
|
||||
expect(subtitle?.classList.contains("xl:whitespace-nowrap")).toBe(true);
|
||||
expect(subtitle?.classList.contains("truncate")).toBe(false);
|
||||
expect(subtitle?.classList.contains("truncate")).toBe(true);
|
||||
const actions = row?.querySelector('button[aria-label="Open actions for Paperclip Engineer With A Much Longer Display Name"]');
|
||||
expect(actions).not.toBeNull();
|
||||
// Neither the action button nor its ancestors may hide the mobile menu.
|
||||
for (let node = actions; node && node !== row; node = node.parentElement) {
|
||||
expect(node.classList.contains("hidden")).toBe(false);
|
||||
}
|
||||
await act(async () => { (actions as HTMLButtonElement).click(); });
|
||||
await flushReact();
|
||||
expect(document.body.textContent).toContain("Duplicate");
|
||||
expect(document.body.textContent).toContain("Terminate");
|
||||
});
|
||||
|
||||
it("uses the built-in agents route segment as the built-in filter", async () => {
|
||||
|
|
@ -959,13 +961,13 @@ describe("Agents", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
// The title cell carries a constant width at xl (`xl:w-56`), not a
|
||||
// The title cell carries a constant width in a wide container, not a
|
||||
// content-sized `min-w-(--sz-7rem)`, so the `meta` group starts at the same
|
||||
// x on every row and the model + timestamp columns line up vertically.
|
||||
// Below xl the meta columns are hidden and the title flexes (`flex-1`)
|
||||
// In narrower containers metadata is hidden and the title flexes (`flex-1`)
|
||||
// instead, so the shrink-0 trailing actions can't squeeze the agent name
|
||||
// to zero width on mobile.
|
||||
const titleCell = container.querySelector(".xl\\:w-56");
|
||||
const titleCell = container.querySelector(".\\@5xl\\:w-56");
|
||||
expect(titleCell).not.toBeNull();
|
||||
expect(titleCell?.textContent).toContain("Alpha");
|
||||
expect(titleCell?.classList.contains("flex-1")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -369,10 +369,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
|
||||
const builtInState = builtInByAgentId.get(agent.id);
|
||||
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
|
||||
// Lifecycle chip + inline `Set up`. Rendered inline in
|
||||
// `meta` at xl (where there's room and the meta columns align) and on a
|
||||
// dedicated full-width line beneath the name below xl, so the chips never
|
||||
// starve the name — the row's primary identifier — at narrow widths.
|
||||
// Keep lifecycle controls with the metadata only when the content area
|
||||
// has enough room; the sidebar can leave less space than the viewport suggests.
|
||||
const builtInCluster = builtInState && showBuiltInLifecycle ? (
|
||||
<>
|
||||
<BuiltInLifecycleChip status={builtInState.status} />
|
||||
|
|
@ -398,18 +396,13 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
<EntityRow
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
// Fixed (truncating) title width at xl so the `meta` group starts at a
|
||||
// constant x on every row — that's what makes the model + timestamp
|
||||
// columns line up vertically. Below xl the meta columns are hidden, so
|
||||
// the title flexes instead: a fixed width there let the shrink-0
|
||||
// trailing actions squeeze the name to zero width on mobile.
|
||||
titleClassName="flex-1 xl:flex-none xl:w-56"
|
||||
titleTextClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
|
||||
subtitleClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
|
||||
titleClassName="flex-1 @5xl:flex-none @5xl:w-56"
|
||||
titleTextClassName="truncate"
|
||||
subtitleClassName="truncate"
|
||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
className={cn(
|
||||
"group",
|
||||
"group py-3",
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "sm:text-foreground/55" : "",
|
||||
)}
|
||||
|
|
@ -419,20 +412,39 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
<AgentStatusCapsule status={agent.status} />
|
||||
)}
|
||||
secondaryRow={
|
||||
builtInCluster ? (
|
||||
<div className="xl:hidden flex flex-wrap items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
<div className="flex flex-col gap-2">
|
||||
{builtInCluster && (
|
||||
<div className="@5xl:hidden flex flex-wrap items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
)}
|
||||
{/* Actions have their own wrapping line, so names keep their width. */}
|
||||
<div
|
||||
className="pt-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={selectedCompanyId}
|
||||
runLabel="Run Heartbeat"
|
||||
showStatus={false}
|
||||
canRunWithProviderTrace={canUseProviderTrace}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
</div>
|
||||
}
|
||||
meta={
|
||||
<div className="flex items-center gap-3">
|
||||
{builtInCluster && (
|
||||
<div className="hidden xl:flex items-center gap-1.5">
|
||||
<div className="hidden @5xl:flex items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<div className="hidden @5xl:flex items-center gap-3">
|
||||
<AgentMetaColumns
|
||||
agent={agent}
|
||||
environment={resolveRenderedEnvironment(agent.id)}
|
||||
|
|
@ -441,7 +453,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
metaSpacerClassName="hidden xl:block"
|
||||
metaSpacerClassName="hidden @5xl:block"
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
|
|
@ -455,23 +467,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
<span className="w-20 flex justify-end">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
{/* Row actions mirror the agent detail page; stop the click
|
||||
from bubbling to the row link so buttons don't navigate.
|
||||
Hidden on mobile so the agent name keeps room to render. */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={selectedCompanyId}
|
||||
runLabel="Run Heartbeat"
|
||||
showStatus={false}
|
||||
canRunWithProviderTrace={canUseProviderTrace}
|
||||
/>
|
||||
</div>
|
||||
<StarToggle
|
||||
size="row"
|
||||
starred={agentStarred}
|
||||
|
|
@ -511,11 +506,12 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
|
||||
return (
|
||||
<div className={cn(
|
||||
"@container",
|
||||
effectiveView === "org"
|
||||
? "flex h-full min-h-0 flex-col gap-4"
|
||||
: "space-y-4",
|
||||
)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
||||
<PageTabBar
|
||||
items={visibleTabItems}
|
||||
|
|
|
|||
|
|
@ -1,433 +1,528 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { NewAgent } from "./NewAgent";
|
||||
|
||||
// The real adapter registry stays in place so the create request runs the real
|
||||
// Claude config builder. That builder turns the fixed binding into the adapter
|
||||
// `env` map, which is the contract this page test verifies.
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
const api = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
adapterModels: vi.fn(),
|
||||
detectModel: vi.fn(),
|
||||
list: vi.fn(),
|
||||
hire: vi.fn(),
|
||||
testEnvironment: vi.fn(),
|
||||
getClaudeOAuthTokenStatus: vi.fn(),
|
||||
startClaudeSetupTokenLogin: vi.fn(),
|
||||
getClaudeSetupTokenLoginStatus: vi.fn(),
|
||||
getClaudeSetupTokenLoginPrompt: vi.fn(),
|
||||
submitClaudeSetupTokenBrowserCode: vi.fn(),
|
||||
completeClaudeSetupTokenLogin: vi.fn(),
|
||||
cancelClaudeSetupTokenLogin: vi.fn(),
|
||||
getAdapterAuthSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() }));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() }));
|
||||
const settings = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
const secrets = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listProposals: vi.fn(),
|
||||
listUserSecretDefinitions: vi.fn(),
|
||||
listMyUserSecrets: vi.fn(),
|
||||
createUserSecretDefinition: vi.fn(),
|
||||
createMyUserSecret: vi.fn(),
|
||||
rotateMyUserSecret: vi.fn(),
|
||||
removeUserSecretDefinition: vi.fn(),
|
||||
}));
|
||||
const mockCompanySkillsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
|
||||
const mockClipboard = vi.hoisted(() => ({ copyTextToClipboard: vi.fn() }));
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const mockAdapterAvailability = vi.hoisted(() => ({
|
||||
disabled: new Set<string>(),
|
||||
loaded: true,
|
||||
const state = vi.hoisted(() => ({
|
||||
params: new URLSearchParams(),
|
||||
adapters: [] as object[],
|
||||
navigate: vi.fn(),
|
||||
openNewIssue: vi.fn(),
|
||||
}));
|
||||
const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearchParams: () => [mockSearchParams.value, vi.fn()],
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: api }));
|
||||
vi.mock("@/api/environments", () => ({ environmentsApi: envApi }));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings }));
|
||||
vi.mock("@/api/secrets", () => ({ secretsApi: secrets }));
|
||||
vi.mock("@/api/adapters", () => ({
|
||||
adaptersApi: { list: async () => state.adapters },
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||
vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi }));
|
||||
vi.mock("../api/companySkills", () => ({ companySkillsApi: mockCompanySkillsApi }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
|
||||
vi.mock("../lib/clipboard", () => ({
|
||||
copyTextToClipboard: mockClipboard.copyTextToClipboard,
|
||||
vi.mock("@/context/DialogContext", () => ({
|
||||
useDialogActions: () => ({ openNewIssue: state.openNewIssue }),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/use-disabled-adapters", () => ({
|
||||
useDisabledAdaptersSync: () => mockAdapterAvailability.disabled,
|
||||
useAdapterRegistryLoaded: () => mockAdapterAvailability.loaded,
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => state.navigate,
|
||||
useSearchParams: () => [state.params],
|
||||
}));
|
||||
|
||||
// The form reads the projected adapter login capability to pick the login flow
|
||||
// and to gate the login panel. The server projects these safe scalar fields.
|
||||
// `claude_local` runs on a real pseudo-terminal, so the panel gate requires the
|
||||
// provider pty capability. Provide the projection so the login panel renders.
|
||||
const mockLoginProjections = vi.hoisted(
|
||||
() =>
|
||||
new Map<string, { panelMode: string; timeoutPolicy: string }>([
|
||||
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
|
||||
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
|
||||
]),
|
||||
);
|
||||
|
||||
vi.mock("../adapters/use-adapter-capabilities", () => ({
|
||||
useAdapterCapabilities: () => (adapterType: string) => {
|
||||
const login = mockLoginProjections.get(adapterType);
|
||||
return {
|
||||
supportsInstructionsBundle: true,
|
||||
supportsSkills: true,
|
||||
supportsLocalAgentJwt: true,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
supportsAcp: true,
|
||||
...(login ? { login } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownEditor", () => ({
|
||||
MarkdownEditor: ({
|
||||
// Exercise API/persistence contracts with deterministic presentation primitives.
|
||||
// The actual searchable dropdown and login panel are covered by their tests and browser verification.
|
||||
vi.mock("@/components/AgentConfigForm", () => ({
|
||||
ModelDropdown: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label={placeholder ?? "Markdown"}
|
||||
<input
|
||||
aria-label="Model"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
AdapterLoginPanel: ({ onStored }: { onStored: (id: string) => void }) => (
|
||||
<button onClick={() => onStored("stored-claim")}>
|
||||
Complete subscription login
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flush React effects and pending promises until a condition holds. The Claude
|
||||
// login chains a start, a status poll, and a completion read, so a single flush
|
||||
// does not settle every state transition.
|
||||
async function flushUntil(check: () => boolean, timeoutMs = 4000) {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) break;
|
||||
await flushReact();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, label: string) {
|
||||
return Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickByText(container: HTMLElement, label: string) {
|
||||
const button = findButton(container, label);
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
const CLAUDE_AUTH_MISSING_RESULT = {
|
||||
adapterType: "claude_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude CLI is installed, but login is required.",
|
||||
},
|
||||
{
|
||||
code: "adapter_auth_missing",
|
||||
level: "warn",
|
||||
message: "The sandbox has no ready authentication for this adapter.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
// The provider capabilities the form fetches. Daytona advertises the
|
||||
// setup-token login capability; E2B does not. The Claude login panel shows only
|
||||
// for a provider with the capability, so the sandbox environment uses Daytona.
|
||||
const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], {
|
||||
sandboxProviders: {
|
||||
daytona: { supportsLoginPty: true, displayName: "Daytona" },
|
||||
e2b: { supportsLoginPty: false, displayName: "E2B" },
|
||||
vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null }));
|
||||
vi.mock("motion/react", () => ({
|
||||
AnimatePresence: ({ children }: any) => children,
|
||||
MotionConfig: ({ children }: any) => children,
|
||||
motion: {
|
||||
span: ({ children }: any) => <span>{children}</span>,
|
||||
div: ({ children, initial, animate, exit, transition, ...rest }: any) => (
|
||||
<div {...rest}>{children}</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
async function renderNewAgent() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
}));
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let cache: QueryClient;
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
}
|
||||
async function click(text: string) {
|
||||
const button = [...container.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === text,
|
||||
);
|
||||
expect(button, `Missing button ${text}`).toBeTruthy();
|
||||
await act(async () => button!.click());
|
||||
await settle();
|
||||
}
|
||||
async function fill(label: string, value: string) {
|
||||
const input = container.querySelector(
|
||||
`[aria-label="${label}"]`,
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
async function render(adapter = "pi_local", runnerProvider = "codex") {
|
||||
state.params = new URLSearchParams({
|
||||
name: "Atlas",
|
||||
adapterType: adapter,
|
||||
runnerProvider,
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<NewAgent />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
<QueryClientProvider client={cache}>
|
||||
<TooltipProvider><NewAgent /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
}
|
||||
async function connect(provider: string) {
|
||||
await click(provider + "Subscription");
|
||||
await click("Connect");
|
||||
}
|
||||
const pass = {
|
||||
adapterType: "pi_local",
|
||||
status: "pass",
|
||||
checks: [
|
||||
{ code: "hello_probe_passed", level: "info", message: "Model replied" },
|
||||
],
|
||||
testedAt: "2026-09-07T00:00:00Z",
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
cache = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
state.adapters = [
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway",
|
||||
].map((type) => ({ type, loaded: true, disabled: false }));
|
||||
api.adapterModels.mockResolvedValue([]);
|
||||
api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]);
|
||||
api.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
|
||||
api.testEnvironment.mockResolvedValue(pass);
|
||||
api.hire.mockImplementation(async (_company, input) => ({
|
||||
agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" },
|
||||
}));
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Local", driver: "local", config: {} },
|
||||
]);
|
||||
envApi.capabilities.mockResolvedValue({ sandboxProviders: {} });
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" });
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
settings.getGeneral.mockResolvedValue({ executionMode: "any" });
|
||||
secrets.list.mockResolvedValue([]);
|
||||
secrets.create.mockResolvedValue({ id: "org-secret-1" });
|
||||
secrets.remove.mockResolvedValue({ ok: true });
|
||||
secrets.listMyUserSecrets.mockResolvedValue([]);
|
||||
secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" });
|
||||
secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" });
|
||||
secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
cache.clear();
|
||||
container.remove();
|
||||
});
|
||||
describe("New agent setup", () => {
|
||||
it("blocks direct runner setup links when the experiment is disabled", async () => {
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
await render("paperclip_runner");
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
it("blocks direct setup links for unsupported Cloud adapters", async () => {
|
||||
cache.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
cloud: { managed: true },
|
||||
});
|
||||
await render("pi_local");
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
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();
|
||||
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
|
||||
await fill("GitHub repository", "https://github.com/paperclipai/paperclip");
|
||||
await fill("Branch", "master");
|
||||
await fill("CURSOR_API_KEY", "cursor-test-key");
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({
|
||||
adapterConfig: { repoUrl: "https://github.com/paperclipai/paperclip", repoStartingRef: "master" },
|
||||
testCredentials: { CURSOR_API_KEY: "cursor-test-key" },
|
||||
});
|
||||
expect(secrets.create).not.toHaveBeenCalled();
|
||||
await click("Finish setup");
|
||||
const config = api.hire.mock.calls[0][1].adapterConfig;
|
||||
expect(config).toMatchObject({ repoUrl: "https://github.com/paperclipai/paperclip", repoStartingRef: "master", env: {
|
||||
CURSOR_API_KEY: { type: "secret_ref", secretId: "org-secret-1", version: "latest" },
|
||||
} });
|
||||
expect(config).not.toHaveProperty("repository");
|
||||
expect(config).not.toHaveProperty("branch");
|
||||
expect(JSON.stringify(config)).not.toContain("cursor-test-key");
|
||||
expect(secrets.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ value: "cursor-test-key" }));
|
||||
});
|
||||
it("requires a new Cursor Cloud key even when organization and personal keys exist", async () => {
|
||||
secrets.list.mockResolvedValue([{ id: "existing", key: "CURSOR_API_KEY", name: "Cursor", status: "active" }]);
|
||||
secrets.listMyUserSecrets.mockResolvedValue([{ definition: { key: "CURSOR_API_KEY" }, secret: { id: "personal-key" } }]);
|
||||
await render("cursor_cloud");
|
||||
expect(container.textContent).not.toContain("Or use an organization secret");
|
||||
await fill("GitHub repository", "https://github.com/example/repo");
|
||||
await click("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Enter a Cursor API key");
|
||||
await fill("CURSOR_API_KEY", "new-cursor-key");
|
||||
await click("Finish setup");
|
||||
expect(secrets.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ value: "new-cursor-key" }));
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.env.CURSOR_API_KEY).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
|
||||
});
|
||||
it.each([
|
||||
["cursor", "CURSOR_API_KEY"],
|
||||
["gemini_local", "GEMINI_API_KEY"],
|
||||
["hermes_local", "OPENROUTER_API_KEY"],
|
||||
])("provides %s credentials to tests and stores only a secret reference", async (adapter, key) => {
|
||||
await render(adapter);
|
||||
await fill(key, "adapter-test-key");
|
||||
await click("Finish setup");
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({ [key]: "adapter-test-key" });
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.env[key]).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
|
||||
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
|
||||
});
|
||||
it("defines a Kimi API model without overriding it with a CLI model alias", async () => {
|
||||
await render("kimi_local");
|
||||
await fill("KIMI_MODEL_API_KEY", "kimi-test-key");
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Enter the Kimi API model name");
|
||||
await fill("Kimi API model name", "kimi-for-coding");
|
||||
await fill("Kimi API base URL", "https://api.kimi.com/coding/v1");
|
||||
await click("Finish setup");
|
||||
const config = api.hire.mock.calls[0][1].adapterConfig;
|
||||
expect(config).not.toHaveProperty("model");
|
||||
expect(config.env).toMatchObject({
|
||||
KIMI_MODEL_NAME: { type: "plain", value: "kimi-for-coding" },
|
||||
KIMI_MODEL_BASE_URL: { type: "plain", value: "https://api.kimi.com/coding/v1" },
|
||||
KIMI_MODEL_API_KEY: { type: "secret_ref", secretId: "org-secret-1" },
|
||||
});
|
||||
});
|
||||
it("configures Hermes Gateway URL and its top-level secret reference", async () => {
|
||||
await render("hermes_gateway");
|
||||
expect(container.querySelector('[aria-label="Model"]')).toBeNull();
|
||||
await fill("Hermes API base URL", "https://hermes.example.com");
|
||||
await fill("API_SERVER_KEY", "hermes-test-key");
|
||||
await click("Finish setup");
|
||||
expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({
|
||||
adapterConfig: { apiBaseUrl: "https://hermes.example.com" },
|
||||
testCredentials: { API_SERVER_KEY: "hermes-test-key" },
|
||||
});
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("hermes-test-key");
|
||||
});
|
||||
it("shows Grok login guidance and hides ignored Kimi and OpenCode effort controls", async () => {
|
||||
await render("grok_local");
|
||||
expect(container.textContent).toContain("grok login");
|
||||
await render("opencode_local");
|
||||
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
|
||||
});
|
||||
it("restores confirmation on refresh without hiring again", async () => {
|
||||
api.get.mockResolvedValue({
|
||||
id: "saved-agent",
|
||||
companyId: "company-1",
|
||||
name: "Atlas",
|
||||
adapterType: "pi_local",
|
||||
adapterConfig: { model: "openrouter/anthropic/claude-sonnet-4.6" },
|
||||
status: "idle",
|
||||
});
|
||||
state.params = new URLSearchParams({
|
||||
name: "Atlas",
|
||||
adapterType: "pi_local",
|
||||
createdAgentId: "saved-agent",
|
||||
});
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<QueryClientProvider client={cache}>
|
||||
<TooltipProvider><NewAgent /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Your agent is ready");
|
||||
expect(container.textContent).not.toContain("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
await click("Assign Atlas a Task");
|
||||
expect(state.openNewIssue).toHaveBeenCalledWith({
|
||||
assigneeAgentId: "saved-agent",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
await flushReact();
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
// Complete the Claude subscription login on the page: run the environment test,
|
||||
// start the login, and let the panel reach the server `stored` state.
|
||||
async function completeClaudeLogin(container: HTMLElement) {
|
||||
await clickByText(container, "Test Agent");
|
||||
await flushUntil(() => Boolean(findButton(container, "Sign in")));
|
||||
await clickByText(container, "Sign in");
|
||||
await flushUntil(() => (container.textContent ?? "").includes("Authenticated"));
|
||||
}
|
||||
|
||||
describe("NewAgent Claude subscription login", () => {
|
||||
let roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockAdapterAvailability.disabled = new Set<string>();
|
||||
mockAdapterAvailability.loaded = true;
|
||||
mockSearchParams.value = new URLSearchParams();
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.detectModel.mockResolvedValue(null);
|
||||
// No existing agents: the page treats the new agent as the first (CEO) and
|
||||
// seeds the name, so the Create button is enabled without a typed name.
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1", name: "CEO" } });
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
// The default owner has no stored Claude token, so the login is a first
|
||||
// write. A test overrides this to prove the panel applies a stored token
|
||||
// first and passes the captured version as a version-checked overwrite.
|
||||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "starting",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: null,
|
||||
it("does not advance connection after a timed-out provider probe", async () => {
|
||||
api.testEnvironment.mockResolvedValue({
|
||||
...pass,
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "claude_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Claude hello probe timed out.",
|
||||
},
|
||||
],
|
||||
});
|
||||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "authenticated",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
});
|
||||
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
|
||||
authorizationUrl: "https://claude.example.test/authorize",
|
||||
});
|
||||
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "authenticated",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
});
|
||||
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
|
||||
storedSessionId: "stored-session-1",
|
||||
});
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockResolvedValue(undefined);
|
||||
|
||||
mockEnvironmentsApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Local", driver: "local", config: {} },
|
||||
{
|
||||
id: "sandbox-1",
|
||||
name: "Daytona",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
},
|
||||
]);
|
||||
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
|
||||
// The instance default is the sandbox, so the effective login environment
|
||||
// resolves to it without the user touching the override selector.
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.listProposals.mockResolvedValue([]);
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
||||
mockCompanySkillsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/asset" });
|
||||
mockClipboard.copyTextToClipboard.mockResolvedValue(undefined);
|
||||
await render("claude_local");
|
||||
await connect("Claude");
|
||||
expect(container.textContent).toContain("Claude hello probe timed out.");
|
||||
expect(container.textContent).not.toContain("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
it.each(["claude_local", "codex_local"])(
|
||||
"connects %s, creates once, and assigns a task",
|
||||
async (adapter) => {
|
||||
await render(adapter);
|
||||
await connect(adapter === "claude_local" ? "Claude" : "OpenAI");
|
||||
expect(api.testEnvironment).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
adapter,
|
||||
expect.objectContaining({ environmentId: "local-1" }),
|
||||
);
|
||||
await click("Finish setup");
|
||||
expect(api.hire).toHaveBeenCalledTimes(1);
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({
|
||||
name: "Atlas",
|
||||
adapterType: adapter,
|
||||
reportsTo: "ceo",
|
||||
runtimeConfig: { heartbeat: { enabled: false } },
|
||||
});
|
||||
}
|
||||
roots = [];
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
expect(container.textContent).toContain("Your agent is ready");
|
||||
await click("Assign Atlas a Task");
|
||||
expect(state.openNewIssue).toHaveBeenCalledWith({
|
||||
assigneeAgentId: "new-agent",
|
||||
status: "todo",
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["claude_local", "claude", "Claude", "ANTHROPIC_API_KEY"],
|
||||
["codex_local", "codex", "OpenAI", "OPENAI_API_KEY"],
|
||||
["paperclip_runner", "claude", "Claude", "ANTHROPIC_API_KEY"],
|
||||
["paperclip_runner", "codex", "OpenAI", "OPENAI_API_KEY"],
|
||||
])("stores %s %s API credentials only when finishing", async (adapter, runner, provider, key) => {
|
||||
await render(adapter, runner);
|
||||
await click("Use API key insteadUse subscription insteadUse API key instead");
|
||||
await click(provider + "API");
|
||||
await fill("API key", "connection-key");
|
||||
await click("Connect");
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({ [key]: "connection-key" });
|
||||
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.env[key].type).toBe("user_secret_ref");
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("connection-key");
|
||||
});
|
||||
|
||||
it("shows Log in for a Claude sandbox before Create agent", async () => {
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
// Before the test the page shows no login affordance.
|
||||
expect(findButton(result.container, "Sign in")).toBeFalsy();
|
||||
|
||||
await clickByText(result.container, "Test Agent");
|
||||
await flushUntil(() => Boolean(findButton(result.container, "Sign in")));
|
||||
|
||||
const loginButton = findButton(result.container, "Sign in");
|
||||
const createButton = findButton(result.container, "Create agent");
|
||||
expect(loginButton).toBeTruthy();
|
||||
expect(createButton).toBeTruthy();
|
||||
|
||||
// The login panel renders before the Create agent button in the document, so
|
||||
// the user completes the subscription login before an agent exists.
|
||||
const order = loginButton!.compareDocumentPosition(createButton!);
|
||||
expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
it.each(["opencode_local", "pi_local"])(
|
||||
"persists %s OpenRouter credentials only as a secret reference",
|
||||
async (adapter) => {
|
||||
await render(adapter);
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await fill("OPENROUTER_API_KEY", "example-test-secret");
|
||||
await click("Run test");
|
||||
expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled();
|
||||
expect(secrets.createMyUserSecret).not.toHaveBeenCalled();
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({ OPENROUTER_API_KEY: "example-test-secret" });
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].adapterConfig.env.OPENROUTER_API_KEY).toEqual({
|
||||
type: "secret_ref",
|
||||
secretId: "org-secret-1",
|
||||
version: "latest",
|
||||
});
|
||||
expect(secrets.create).toHaveBeenCalledWith(
|
||||
"company-1", expect.objectContaining({ value: "example-test-secret" }),
|
||||
);
|
||||
expect(JSON.stringify(api.hire.mock.calls)).not.toContain(
|
||||
"example-test-secret",
|
||||
);
|
||||
expect(secrets.create).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it.each(["codex", "claude", "opencode"])(
|
||||
"uses the correct native %s runner",
|
||||
async (runner) => {
|
||||
await render("paperclip_runner", runner);
|
||||
if (runner !== "opencode")
|
||||
await connect(runner === "claude" ? "Claude" : "OpenAI");
|
||||
else await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await click("Finish setup");
|
||||
const config = api.hire.mock.calls[0][1].adapterConfig;
|
||||
expect(config.provider).toBe(runner === "claude" ? "acpx" : runner);
|
||||
if (runner === "claude") {
|
||||
expect(config.acpxAgent).toBe("claude");
|
||||
expect(config.model).toMatch(/^claude-/);
|
||||
}
|
||||
if (runner === "codex") expect(config.acpxAgent).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it("does not create when the test fails and permits retry", async () => {
|
||||
await render();
|
||||
await fill("Model", "openrouter/unknown/model");
|
||||
api.testEnvironment.mockResolvedValueOnce({ ...pass, status: "fail" });
|
||||
await click("Run test");
|
||||
await click("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Couldn't connect");
|
||||
await click("Retry test");
|
||||
await click("Finish setup");
|
||||
expect(api.hire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stores the secret and adds the fixed binding through the create request, with no token in the DOM", async () => {
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
await completeClaudeLogin(result.container);
|
||||
|
||||
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"claude-session-1",
|
||||
);
|
||||
|
||||
await clickByText(result.container, "Create agent");
|
||||
await flushUntil(() => mockAgentsApi.hire.mock.calls.length > 0);
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
const [companyId, payload] = mockAgentsApi.hire.mock.calls[0] as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
it("does not rotate a working provider credential when a new key fails its test", async () => {
|
||||
secrets.listMyUserSecrets.mockResolvedValue([{ definition: { id: "existing-definition", key: "OPENROUTER_API_KEY" }, secret: { id: "working-secret" } }]);
|
||||
await render();
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await fill("OPENROUTER_API_KEY", "invalid-replacement");
|
||||
api.testEnvironment.mockResolvedValueOnce({ ...pass, status: "fail" });
|
||||
await click("Run test");
|
||||
expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled();
|
||||
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
|
||||
expect(api.testEnvironment.mock.calls[0][2].testCredentials.OPENROUTER_API_KEY).toBe("invalid-replacement");
|
||||
await click("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not store a key when leaving after a successful test", async () => {
|
||||
await render();
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await fill("OPENROUTER_API_KEY", "abandoned-key");
|
||||
await click("Run test");
|
||||
await act(async () => root.render(null));
|
||||
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("removes the staged credential when hiring fails", async () => {
|
||||
await render();
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await fill("OPENROUTER_API_KEY", "new-key");
|
||||
api.hire.mockRejectedValueOnce(new Error("Creation rejected"));
|
||||
await click("Finish setup");
|
||||
expect(secrets.remove).toHaveBeenCalledWith("org-secret-1");
|
||||
expect(container.textContent).toContain("Creation rejected");
|
||||
});
|
||||
it("preserves the creation error when credential cleanup also fails", async () => {
|
||||
await render();
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await fill("OPENROUTER_API_KEY", "new-key");
|
||||
api.hire.mockRejectedValueOnce(new Error("Agent quota exceeded"));
|
||||
secrets.remove.mockRejectedValueOnce(new Error("Cleanup unavailable"));
|
||||
await click("Finish setup");
|
||||
expect(container.textContent).toContain("Agent quota exceeded");
|
||||
expect(container.textContent).toContain("Could not remove an unused setup credential");
|
||||
});
|
||||
it("requires an explicit provider/model for Pi", async () => {
|
||||
await render();
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("provider/model format");
|
||||
});
|
||||
it("blocks a disabled runner even when opened through a URL", async () => {
|
||||
state.adapters = [
|
||||
{ type: "paperclip_runner", loaded: true, disabled: true },
|
||||
];
|
||||
expect(companyId).toBe("company-1");
|
||||
|
||||
// The create request carries the non-secret stored-session claim, and the
|
||||
// adapter config carries the fixed reference binding.
|
||||
expect(payload.storedSessionId).toBe("stored-session-1");
|
||||
const adapterConfig = payload.adapterConfig as Record<string, unknown>;
|
||||
const env = adapterConfig.env as Record<string, Record<string, unknown>>;
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toEqual({
|
||||
type: "user_secret_ref",
|
||||
key: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
version: "latest",
|
||||
required: true,
|
||||
});
|
||||
// The binding is a reference. It carries no token value.
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).not.toHaveProperty("value");
|
||||
|
||||
// The token never enters the Document Object Model, and no request payload
|
||||
// carries a token value.
|
||||
expect(result.container.textContent ?? "").not.toContain("sk-ant");
|
||||
expect(JSON.stringify(payload)).not.toContain("sk-ant");
|
||||
await render("paperclip_runner");
|
||||
await connect("OpenAI");
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies a stored token first and starts a replacement login with the captured version", async () => {
|
||||
// The owner already has a stored Claude token. The panel reads the status,
|
||||
// applies the stored token first, and shows a replace action. A replacement
|
||||
// login carries the captured secret id and version, so the server rotates the
|
||||
// stored value under a version-checked overwrite instead of a first write.
|
||||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
|
||||
secretId: "44444444-4444-4444-8444-444444444444",
|
||||
latestVersion: 3,
|
||||
it("keeps pending agents behind approval and disables task assignment", async () => {
|
||||
api.hire.mockResolvedValue({
|
||||
agent: { id: "new-agent", name: "Atlas", status: "pending_approval" },
|
||||
});
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
await clickByText(result.container, "Test Agent");
|
||||
// The panel shows the replace action only after it reads the stored-token
|
||||
// status, so the button label proves the panel captured the version.
|
||||
await flushUntil(() => Boolean(findButton(result.container, "Sign in to replace")));
|
||||
await clickByText(result.container, "Sign in to replace");
|
||||
await flushUntil(() => mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length > 0);
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith("company-1", {
|
||||
environmentId: "sandbox-1",
|
||||
overwrite: {
|
||||
expectedSecretId: "44444444-4444-4444-8444-444444444444",
|
||||
expectedLatestVersion: 3,
|
||||
},
|
||||
await render();
|
||||
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
|
||||
await click("Finish setup");
|
||||
expect(container.textContent).toContain("Agent submitted for approval");
|
||||
await click("Assign Atlas a Task");
|
||||
expect(state.openNewIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the same managed environment for connection testing and creation", async () => {
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona" } },
|
||||
]);
|
||||
envApi.capabilities.mockResolvedValue({
|
||||
sandboxProviders: { daytona: { supportsLoginPty: true } },
|
||||
});
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
|
||||
settings.getExperimental.mockResolvedValue({
|
||||
enableManagedSandboxOnly: true,
|
||||
});
|
||||
await render("claude_local");
|
||||
await connect("Claude");
|
||||
await click("Finish setup");
|
||||
expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe(
|
||||
"sandbox-1",
|
||||
);
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({
|
||||
defaultEnvironmentId: "sandbox-1",
|
||||
applyStoredClaudeLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a native-runner URL preset while the experimental adapter is disabled", async () => {
|
||||
mockSearchParams.value = new URLSearchParams("adapterType=paperclip_runner");
|
||||
mockAdapterAvailability.disabled = new Set(["paperclip_runner"]);
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).not.toContain("Paperclip Runner");
|
||||
expect(result.container.textContent).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("accepts a native-runner URL preset after the experimental adapter is enabled", async () => {
|
||||
mockSearchParams.value = new URLSearchParams("adapterType=paperclip_runner");
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).toContain("Paperclip Runner");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,449 +1,14 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useEffect } from "react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { resolveSkillSummaryText } from "../lib/company-skill-summary";
|
||||
import { AGENT_ROLES, type AdapterEnvironmentTestResult, type AgentPermissions } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Shield } from "lucide-react";
|
||||
import { cn, agentUrl } from "../lib/utils";
|
||||
import { roleLabels } from "../components/agent-config-primitives";
|
||||
import {
|
||||
AgentConfigForm,
|
||||
AdapterEnvironmentResult,
|
||||
AdapterLoginPanel,
|
||||
type AdapterLoginDescriptor,
|
||||
type CreateConfigValues,
|
||||
} from "../components/AgentConfigForm";
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { buildFixedClaudeOAuthBinding } from "../components/environment-variables-editor/model";
|
||||
import type { EnvBinding } from "@paperclipai/shared";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import {
|
||||
useAdapterRegistryLoaded,
|
||||
useDisabledAdaptersSync,
|
||||
} from "../adapters/use-disabled-adapters";
|
||||
import { isValidAdapterType } from "../adapters/metadata";
|
||||
import { ReportsToPicker } from "../components/ReportsToPicker";
|
||||
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
|
||||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||||
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
|
||||
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
|
||||
|
||||
function createValuesForAdapterType(
|
||||
adapterType: CreateConfigValues["adapterType"],
|
||||
): CreateConfigValues {
|
||||
const { adapterType: _discard, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType };
|
||||
if (adapterType === "codex_local") {
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (adapterType === "gemini_local") {
|
||||
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
} else if (adapterType === "kimi_local") {
|
||||
nextValues.model = DEFAULT_KIMI_LOCAL_MODEL;
|
||||
} else if (adapterType === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
import { NewAgentSetup } from "../components/new-agent/NewAgentSetup";
|
||||
|
||||
export function NewAgent() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const presetAdapterType = searchParams.get("adapterType");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [role, setRole] = useState("general");
|
||||
const [reportsTo, setReportsTo] = useState<string | null>(null);
|
||||
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
||||
const [permissions, setPermissions] = useState<Partial<AgentPermissions>>(
|
||||
buildPermissionsForTrustPreset(null, "standard"),
|
||||
);
|
||||
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [testAgentAction, setTestAgentAction] = useState<(() => void) | null>(null);
|
||||
const [testAgentState, setTestAgentState] = useState({ disabled: true, pending: false });
|
||||
const [testAgentFeedback, setTestAgentFeedback] = useState<{
|
||||
errorMessage: string | null;
|
||||
result: AdapterEnvironmentTestResult | null;
|
||||
login: AdapterLoginDescriptor | null;
|
||||
}>({
|
||||
errorMessage: null,
|
||||
result: null,
|
||||
login: null,
|
||||
});
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
const adapterRegistryLoaded = useAdapterRegistryLoaded();
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: companySkills } = useQuery({
|
||||
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
|
||||
queryFn: () => companySkillsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const lowTrustSelected = getTrustPreset(permissions) === "low_trust_review";
|
||||
|
||||
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "__low-trust-disabled"],
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && lowTrustSelected),
|
||||
});
|
||||
|
||||
const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? [...queryKeys.issues.list(selectedCompanyId), "low-trust-boundary-candidates"]
|
||||
: ["issues", "__low-trust-disabled"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
|
||||
enabled: Boolean(selectedCompanyId && lowTrustSelected),
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Agents", href: "/agents" },
|
||||
{ label: "New Agent" },
|
||||
{ label: "New agent" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstAgent) {
|
||||
if (!name) setName("CEO");
|
||||
if (!title) setTitle("CEO");
|
||||
}
|
||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const requested = presetAdapterType;
|
||||
if (!requested) return;
|
||||
if (!adapterRegistryLoaded || disabledTypes.has(requested)) return;
|
||||
if (!isValidAdapterType(requested)) return;
|
||||
setConfigValues((prev) => {
|
||||
if (prev.adapterType === requested) return prev;
|
||||
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||
});
|
||||
}, [adapterRegistryLoaded, disabledTypes, presetAdapterType]);
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(agentUrl(result.agent));
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||
},
|
||||
});
|
||||
|
||||
function buildAdapterConfig() {
|
||||
const adapter = getUIAdapter(configValues.adapterType);
|
||||
return adapter.buildAdapterConfig(configValues);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
setFormError(null);
|
||||
if (configValues.adapterType === "opencode_local") {
|
||||
if (!isValidOpenCodeModelId(configValues.model)) {
|
||||
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
createAgent.mutate(
|
||||
buildNewAgentHirePayload({
|
||||
name,
|
||||
effectiveRole,
|
||||
title,
|
||||
reportsTo,
|
||||
selectedSkillKeys,
|
||||
configValues,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
permissions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/"));
|
||||
|
||||
function toggleSkill(key: string, checked: boolean) {
|
||||
setSelectedSkillKeys((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(key) ? prev : [...prev, key];
|
||||
}
|
||||
return prev.filter((value) => value !== key);
|
||||
});
|
||||
}
|
||||
|
||||
// Add the fixed CLAUDE_CODE_OAUTH_TOKEN binding after a Claude subscription
|
||||
// login reaches the server `stored` state. The new-agent page lifts the login
|
||||
// feedback and renders the panel itself, so it holds the stored-session claim
|
||||
// and the fixed binding in the create-mode values here. The claim is a
|
||||
// reference, not a token; the create request sends it, and the server binds
|
||||
// and enforces the token. Keep every unrelated binding.
|
||||
const handleClaudeLoginStored = useCallback((storedSessionId: string) => {
|
||||
setConfigValues((prev) => ({
|
||||
...prev,
|
||||
envBindings: {
|
||||
...((prev.envBindings ?? {}) as Record<string, EnvBinding>),
|
||||
...buildFixedClaudeOAuthBinding(),
|
||||
},
|
||||
claudeStoredSessionId: storedSessionId,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Bind the fixed CLAUDE_CODE_OAUTH_TOKEN reference to an existing stored login
|
||||
// with no new login round trip. Add the fixed binding and set the apply-existing
|
||||
// flag on the create-mode values. The create request sends the flag; the server
|
||||
// binds the token only for a user actor and only when a stored value exists.
|
||||
// Keep every unrelated binding.
|
||||
const handleApplyStoredClaudeLogin = useCallback(() => {
|
||||
setConfigValues((prev) => ({
|
||||
...prev,
|
||||
envBindings: {
|
||||
...((prev.envBindings ?? {}) as Record<string, EnvBinding>),
|
||||
...buildFixedClaudeOAuthBinding(),
|
||||
},
|
||||
claudeApplyStoredLogin: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTestAgentActionChange = useCallback((fn: (() => void) | null) => {
|
||||
setTestAgentAction(() => fn);
|
||||
}, []);
|
||||
|
||||
const handleTestAgentStateChange = useCallback((state: { disabled: boolean; pending: boolean }) => {
|
||||
setTestAgentState(state);
|
||||
}, []);
|
||||
|
||||
const handleTestAgentFeedbackChange = useCallback((feedback: {
|
||||
errorMessage: string | null;
|
||||
result: AdapterEnvironmentTestResult | null;
|
||||
login: AdapterLoginDescriptor | null;
|
||||
}) => {
|
||||
setTestAgentFeedback(feedback);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">New Agent</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Advanced agent configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-border">
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<input
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Agent name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pb-2">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
||||
placeholder="Title (e.g. VP of Engineering)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property chips: Role + Reports To */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
||||
isFirstAgent && "opacity-60 cursor-not-allowed"
|
||||
)}
|
||||
disabled={isFirstAgent}
|
||||
>
|
||||
<Shield className="h-3 w-3 text-muted-foreground" />
|
||||
{roleLabels[effectiveRole] ?? effectiveRole}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{AGENT_ROLES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
r === role && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setRole(r); setRoleOpen(false); }}
|
||||
>
|
||||
{roleLabels[r] ?? r}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<ReportsToPicker
|
||||
agents={agents ?? []}
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
disabled={isFirstAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-4">
|
||||
<TrustPresetSection
|
||||
permissions={permissions}
|
||||
onChange={setPermissions}
|
||||
disabled={createAgent.isPending}
|
||||
companyId={selectedCompanyId}
|
||||
projectCandidates={(boundaryProjects ?? []).map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
}))}
|
||||
issueCandidates={(boundaryIssues ?? []).map((issue) => ({
|
||||
id: issue.id,
|
||||
label: `${issue.identifier ?? issue.id.slice(0, 8)} · ${issue.title}`,
|
||||
}))}
|
||||
candidatesLoading={boundaryProjectsLoading || boundaryIssuesLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shared config form */}
|
||||
<AgentConfigForm
|
||||
mode="create"
|
||||
values={configValues}
|
||||
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
||||
onTestActionChange={handleTestAgentActionChange}
|
||||
onTestActionStateChange={handleTestAgentStateChange}
|
||||
onTestFeedbackChange={handleTestAgentFeedbackChange}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Organization skills</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Optional skills from the organization library. Built-in Paperclip runtime skills are added automatically.
|
||||
</p>
|
||||
</div>
|
||||
{availableSkills.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No optional organization skills installed yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableSkills.map((skill) => {
|
||||
const inputId = `skill-${skill.id}`;
|
||||
const checked = selectedSkillKeys.includes(skill.key);
|
||||
const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true });
|
||||
return (
|
||||
<div key={skill.id} className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleSkill(skill.key, next === true)}
|
||||
/>
|
||||
<label htmlFor={inputId} className="grid gap-1 leading-none">
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
{summaryText ? <span className="text-xs text-muted-foreground">{summaryText}</span> : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{isFirstAgent && (
|
||||
<p className="text-xs text-muted-foreground mb-2">This will be the CEO</p>
|
||||
)}
|
||||
{formError && (
|
||||
<p className="text-xs text-destructive mb-2">{formError}</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{testAgentFeedback.errorMessage && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{testAgentFeedback.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{testAgentFeedback.result && (
|
||||
<AdapterEnvironmentResult result={testAgentFeedback.result} />
|
||||
)}
|
||||
{testAgentFeedback.login && (
|
||||
<AdapterLoginPanel
|
||||
key={`${testAgentFeedback.login.adapterType}:${testAgentFeedback.login.environmentId}`}
|
||||
companyId={testAgentFeedback.login.companyId}
|
||||
adapterType={testAgentFeedback.login.adapterType}
|
||||
environmentId={testAgentFeedback.login.environmentId}
|
||||
onStored={handleClaudeLoginStored}
|
||||
onApplyStored={handleApplyStoredClaudeLogin}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testAgentState.disabled}
|
||||
onClick={() => testAgentAction?.()}
|
||||
>
|
||||
{testAgentState.pending ? "Testing..." : "Test Agent"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createAgent.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createAgent.isPending ? "Creating…" : "Create agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <NewAgentSetup />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
@import "../../src/index.css";
|
||||
@source "../../src";
|
||||
@source "../prototypes";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion, MotionConfig } from "motion/react";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Plus, RotateCcw, Settings2, Users } from "lucide-react";
|
||||
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
|
||||
import { AdapterMark as AgentAdapterMark } from "@/components/new-agent/AgentBasicsDialog";
|
||||
import { setupEfforts, SETUP_CREDENTIAL_KEYS, SETUP_LOGIN_HINTS } from "@/lib/agent-setup-fields";
|
||||
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { PillGuy } from "@/components/onboarding/PillGuy";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderConnectionPreview, type ConnectionMethod, type ConnectionProvider } from "./ProviderConnectionPreview";
|
||||
|
||||
import { RuntimeTestCard } from "./RuntimeTestCard";
|
||||
import { ModelDropdown } from "@/components/AgentConfigForm";
|
||||
import { OnboardingCard, OnboardingHeading } from "@/components/onboarding/OnboardingPrimitives";
|
||||
import { Field } from "@/components/agent-config-primitives";
|
||||
import { NewIssueDialog } from "@/components/NewIssueDialog";
|
||||
import { useDialog } from "@/context/DialogContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import type { AdapterEnvironmentTestResult, Agent } from "@paperclipai/shared";
|
||||
import { storybookAgents, storybookHiredAgent } from "../fixtures/paperclipData";
|
||||
import { PREVIEW_AGENT_ID, PREVIEW_COMPANY_ID, runtimeTestResult, useNewAgentFixtures, type TestOutcome, type TestState } from "./new-agent-fixtures";
|
||||
import { stepMotion } from "@/components/onboarding/onboarding-motion";
|
||||
import { models as claudeModels } from "@paperclipai/adapter-claude-local";
|
||||
import { models as codexModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as openCodeModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { models as geminiModels } from "@paperclipai/adapter-gemini-local";
|
||||
import { models as grokModels } from "@paperclipai/adapter-grok-local";
|
||||
import { models as kimiModels } from "@paperclipai/adapter-kimi-local";
|
||||
|
||||
const modelLists = {
|
||||
claude_local: claudeModels, codex_local: codexModels, opencode_local: openCodeModels,
|
||||
cursor: cursorModels, gemini_local: geminiModels, grok_local: grokModels, kimi_local: kimiModels,
|
||||
cursor_cloud: [], pi_local: [], hermes_local: [],
|
||||
};
|
||||
|
||||
// Review-only flow. All edits and the simulated hire live in React state;
|
||||
// importing this from Storybook never creates an agent or contacts a provider.
|
||||
export const NEW_AGENT_ADAPTERS = [
|
||||
"claude_local", "codex_local", "cursor", "cursor_cloud", "gemini_local",
|
||||
"grok_local", "kimi_local", "opencode_local", "pi_local", "hermes_local", "paperclip_runner",
|
||||
] as const;
|
||||
export type NewAgentAdapter = typeof NEW_AGENT_ADAPTERS[number];
|
||||
export type NewAgentScreen = "name" | "adapter" | "connect" | "runtime" | "saved";
|
||||
|
||||
export type RunnerProvider = "Codex (app server)" | "OpenCode" | "Claude (ACPX)";
|
||||
|
||||
type RuntimeDraft = {
|
||||
model: string; effort: string; repoUrl: string; branch: string; apiKey: string; kimiModel: string;
|
||||
};
|
||||
function defaultRuntime(): RuntimeDraft {
|
||||
return { model: "", effort: "Auto", repoUrl: "", branch: "", apiKey: "", kimiModel: "" };
|
||||
}
|
||||
|
||||
function AdapterMark({ adapter }: { adapter: NewAgentAdapter }) {
|
||||
return <AgentAdapterMark type={adapter} />;
|
||||
}
|
||||
|
||||
function TextField({ label, hint, value, onChange, placeholder, required, invalid, readOnly, type = "text" }: {
|
||||
label: string; hint?: string; value: string; onChange: (value: string) => void;
|
||||
placeholder?: string; required?: boolean; invalid?: boolean; readOnly?: boolean; type?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
return <div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="text-sm font-medium">{label}{required && <span className="text-muted-foreground"> (required)</span>}</label>
|
||||
<Input id={id} type={type} value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder}
|
||||
required={required} readOnly={readOnly} aria-invalid={invalid || undefined} aria-describedby={hint ? `${id}-hint` : undefined} />
|
||||
{hint && <p id={`${id}-hint`} className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SelectField({ label, value, onChange, options, hint, hideLabel = false }: {
|
||||
label: string; value: string; onChange: (value: string) => void; options: string[]; hint?: string; hideLabel?: boolean;
|
||||
}) {
|
||||
const control = <select aria-label={label} value={value} onChange={event => onChange(event.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(option => <option key={option}>{option}</option>)}
|
||||
</select>;
|
||||
return hideLabel ? control : <Field label={label} hint={hint}>{control}</Field>;
|
||||
}
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return <section className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1"><h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}</div>
|
||||
{children}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function NewAgentWizard({ initialScreen = "name", initialAdapter = null, initialName = "", initialOpen = true, cloud = false, nativeRunnerEnabled = true,
|
||||
initialRunnerProvider = "Codex (app server)", initialConnectionMethod = "subscription", initialConnectionWaiting = false, initialTestState = "idle", testOutcome = "pass", testDelayMs = 1200,
|
||||
}: {
|
||||
cloud?: boolean; nativeRunnerEnabled?: boolean;
|
||||
initialScreen?: NewAgentScreen; initialAdapter?: NewAgentAdapter | null; initialName?: string; initialOpen?: boolean;
|
||||
initialRunnerProvider?: RunnerProvider; initialConnectionMethod?: ConnectionMethod; initialConnectionWaiting?: boolean;
|
||||
initialTestState?: TestState; testOutcome?: TestOutcome; testDelayMs?: number;
|
||||
}) {
|
||||
const { openNewIssue, closeNewIssue } = useDialog();
|
||||
const { setSelectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const [taskCreated, setTaskCreated] = useState<string | null>(null);
|
||||
const [testState, setTestState] = useState<TestState>(initialTestState);
|
||||
const [testResult, setTestResult] = useState<AdapterEnvironmentTestResult | null>(() => initialTestState === "pass" || initialTestState === "fail"
|
||||
? runtimeTestResult(initialAdapter ?? "claude_local", initialTestState, "", "Organization default") : null);
|
||||
const testRun = useRef(0);
|
||||
const resetTest = () => { testRun.current++; setTestState("idle"); setTestResult(null); };
|
||||
const [screen, setScreen] = useState<NewAgentScreen>(initialScreen);
|
||||
const [name, setName] = useState(initialName);
|
||||
const [adapter, setAdapter] = useState<NewAgentAdapter | null>(initialAdapter);
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
const [drafts, setDrafts] = useState<Record<string, RuntimeDraft>>({});
|
||||
const [environments, setEnvironments] = useState<Partial<Record<NewAgentAdapter, string>>>({});
|
||||
const [runnerProvider, setRunnerProvider] = useState<RunnerProvider>(initialRunnerProvider);
|
||||
const [connections, setConnections] = useState<Record<string, ConnectionMethod | undefined>>(() => {
|
||||
if (initialScreen !== "runtime" && initialScreen !== "saved") return {};
|
||||
const key = initialAdapter === "paperclip_runner" ? `${initialAdapter}/${initialRunnerProvider}` : initialAdapter ?? "claude_local";
|
||||
return { [key]: initialConnectionMethod };
|
||||
});
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const nameId = useId();
|
||||
const modal = screen === "name" || screen === "adapter";
|
||||
const selected = adapter ?? "claude_local";
|
||||
const display = getAdapterDisplay(selected);
|
||||
const environment = environments[selected] ?? "Organization default";
|
||||
const isRunner = selected === "paperclip_runner";
|
||||
const isAcpx = isRunner && runnerProvider === "Claude (ACPX)";
|
||||
const usesOpenCode = selected === "opencode_local" || (isRunner && runnerProvider === "OpenCode");
|
||||
// Preserve model choices independently for each runner provider.
|
||||
const draftKey = isRunner ? `${selected}/${runnerProvider}` : selected;
|
||||
const connectionProvider: ConnectionProvider | null = selected === "claude_local" || isAcpx ? "Claude"
|
||||
: selected === "codex_local" || (isRunner && runnerProvider === "Codex (app server)") ? "OpenAI" : null;
|
||||
const connectionKey = draftKey;
|
||||
const connectedMethod = connections[connectionKey];
|
||||
const brandAdapter = isRunner ? isAcpx ? "claude_local" : runnerProvider === "OpenCode" ? "opencode_local" : "codex_local" : selected;
|
||||
const models = modelLists[brandAdapter as keyof typeof modelLists];
|
||||
const draft = drafts[draftKey] ?? defaultRuntime();
|
||||
const setDraft = (patch: Partial<RuntimeDraft>) => {
|
||||
setDrafts(previous => ({ ...previous, [draftKey]: { ...(previous[draftKey] ?? defaultRuntime()), ...patch } }));
|
||||
setError(null); resetTest();
|
||||
};
|
||||
const go = (next: NewAgentScreen) => { setError(null); setScreen(next); };
|
||||
useEffect(() => { if (!modal) heading.current?.focus(); }, [screen, modal]);
|
||||
|
||||
function validateRuntime() {
|
||||
if (selected === "cursor_cloud" && !/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/?$/.test(draft.repoUrl.trim())) {
|
||||
setError("Enter the GitHub repository URL for this Cursor Cloud agent.");
|
||||
return false;
|
||||
}
|
||||
if (selected === "cursor_cloud" && !draft.apiKey.trim()) {
|
||||
setError("Enter a Cursor API key.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapterConfig = {
|
||||
...(draft.model.trim() ? { model: draft.model.trim() } : {}),
|
||||
...(draft.effort !== "Auto" ? { [isRunner ? "thinkingEffort" : selected === "codex_local" ? "modelReasoningEffort" : "thinkingEffort"]: draft.effort.toLowerCase() } : {}),
|
||||
...(isRunner ? { provider: isAcpx ? "acpx" : runnerProvider === "OpenCode" ? "opencode" : "codex", ...(isAcpx ? { acpxAgent: "claude" } : {}) } : {}),
|
||||
...(selected === "cursor_cloud" ? { repoUrl: draft.repoUrl, repoStartingRef: draft.branch } : {}),
|
||||
};
|
||||
const previewAgent: Agent = { ...storybookHiredAgent, id: PREVIEW_AGENT_ID, name: name || "Darnold", adapterType: selected, adapterConfig };
|
||||
useNewAgentFixtures(previewAgent, testOutcome, testDelayMs, setTaskCreated);
|
||||
useEffect(() => () => {
|
||||
testRun.current++; closeNewIssue();
|
||||
queryClient.setQueryData<Agent[]>(queryKeys.agents.list(PREVIEW_COMPANY_ID), previous => previous?.filter(agent => agent.id !== PREVIEW_AGENT_ID));
|
||||
}, [closeNewIssue, queryClient]);
|
||||
async function runTest() {
|
||||
if (!validateRuntime()) return;
|
||||
const run = ++testRun.current;
|
||||
setTestState("running"); setTestResult(null); setError(null);
|
||||
try {
|
||||
const result = await agentsApi.testEnvironment(PREVIEW_COMPANY_ID, selected, {
|
||||
adapterConfig,
|
||||
environmentId: environment === "Paperclip Computer" ? "environment-storybook-sandbox"
|
||||
: environment === "Local machine" ? "environment-storybook-local" : null,
|
||||
});
|
||||
if (testRun.current !== run) return;
|
||||
setTestResult(result); setTestState(result.status === "pass" ? "pass" : "fail");
|
||||
} catch (cause) {
|
||||
if (testRun.current !== run) return;
|
||||
setTestState("fail"); setError(cause instanceof Error ? cause.message : "Runtime test failed. Try again.");
|
||||
}
|
||||
}
|
||||
useEffect(() => { if (initialTestState === "running") void runTest(); }, []);
|
||||
function assignTask() {
|
||||
setSelectedCompanyId(PREVIEW_COMPANY_ID);
|
||||
queryClient.setQueryData(queryKeys.agents.list(PREVIEW_COMPANY_ID), [...storybookAgents, previewAgent]);
|
||||
openNewIssue({ assigneeAgentId: PREVIEW_AGENT_ID, status: "todo" });
|
||||
}
|
||||
const steps: { screen: NewAgentScreen; label: string }[] = [
|
||||
...(connectionProvider ? [{ screen: "connect" as const, label: "Connect" }] : []),
|
||||
{ screen: "runtime", label: "Configure" }, { screen: "saved", label: "Confirmation" },
|
||||
];
|
||||
const currentStep = steps.findIndex(step => step.screen === screen);
|
||||
|
||||
return <MotionConfig reducedMotion="user">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-6 py-3">
|
||||
<span className="text-xs text-muted-foreground">Design preview · Changes stay in this story</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => {
|
||||
resetTest(); setTaskCreated(null); setName(""); setAdapter(null); setDrafts({}); setEnvironments({}); setRunnerProvider("Codex (app server)"); setConnections({}); go("name"); setOpen(true);
|
||||
}}><RotateCcw className="size-3.5" />Start over</Button>
|
||||
</div>
|
||||
|
||||
{modal ? <>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-8 px-6 py-10">
|
||||
<div className="flex items-center justify-between"><div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold">Agents</h1><p className="text-sm text-muted-foreground">The people behind your company.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button><Plus className="size-4" />New agent</Button></DialogTrigger>
|
||||
<DialogContent className={cn("flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-(--sz-640px)", screen === "name" && "sm:max-w-(--sz-560px)")}>
|
||||
<div className="flex items-center gap-2 px-6 py-5 text-xs text-muted-foreground" aria-label="New agent progress">
|
||||
<span className={cn(screen === "name" && "text-foreground font-medium")}>1. Name</span><ChevronRight className="size-3" />
|
||||
<span className={cn(screen === "adapter" && "text-foreground font-medium")}>2. Adapter</span>
|
||||
</div>
|
||||
<form className="flex min-h-0 flex-col" onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
if (screen === "name") go("adapter");
|
||||
else if (adapter) { setName(name.trim()); setOpen(false); go(connectionProvider ? "connect" : "runtime"); }
|
||||
}}>
|
||||
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<PillGuy state="dormant" className="size-16" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<DialogTitle className="text-3xl font-semibold tracking-tight">{screen === "name" ? "Meet your next agent" : "Choose an adapter"}</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
{screen === "name" ? "Start with a name. Make them your own." : `How should ${name.trim() || "your agent"} work?`}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
{screen === "name" ? <div className="flex flex-col gap-2">
|
||||
<label htmlFor={nameId} className="text-sm font-medium">Agent name</label>
|
||||
<Input id={nameId} autoFocus maxLength={100} placeholder="e.g. Darnold" value={name} onChange={event => setName(event.target.value)} className="h-12 text-base" />
|
||||
</div> : <fieldset className="flex flex-col gap-3">
|
||||
<legend className="sr-only">Adapter</legend>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{NEW_AGENT_ADAPTERS.filter(type => isNewAgentAdapterAllowed(type, { cloud, nativeRunnerEnabled })).map(type => {
|
||||
const info = getAdapterDisplay(type);
|
||||
return <label key={type} className="relative cursor-pointer">
|
||||
<input type="radio" name="new-agent-adapter" value={type} checked={adapter === type} onChange={() => setAdapter(type)} className="peer sr-only" />
|
||||
<span className={cn("flex h-full flex-col items-center gap-2 rounded-lg border px-3 py-4 text-center peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent/40", adapter === type ? "border-foreground/40 bg-accent" : "border-border bg-card")}>
|
||||
<span aria-hidden="true"><AdapterMark adapter={type} /></span>
|
||||
<span className="text-sm font-medium">{info.label}</span>
|
||||
{adapter === type && <Check className="absolute right-2 top-2 size-3.5" />}
|
||||
</span>
|
||||
</label>;
|
||||
})}
|
||||
</div>
|
||||
{adapter === "paperclip_runner" && <SelectField label="Runner" value={runnerProvider}
|
||||
options={["Codex (app server)", "Claude (ACPX)", "OpenCode"]} onChange={value => setRunnerProvider(value as RunnerProvider)} />}
|
||||
</fieldset>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
|
||||
<Button type="button" variant="ghost" onClick={() => screen === "name" ? setOpen(false) : go("name")}>
|
||||
{screen === "name" ? "Cancel" : <><ArrowLeft className="size-4" />Back</>}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || (screen === "adapter" && !adapter)}>
|
||||
{screen === "name" ? "Choose adapter" : "Create agent"}<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-border p-5"><Users className="size-8 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1"><p className="text-sm font-medium">Build your team</p><p className="text-sm text-muted-foreground">Create a new agent to get started.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</> : <div className="mx-auto flex max-w-5xl flex-col gap-8 px-6 py-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><span>Agents</span><ChevronRight className="size-3" /><span>{name || "Darnold"}</span><ChevronRight className="size-3" /><span className="text-foreground">{screen === "connect" ? "Connect" : screen === "saved" ? "Confirmation" : "Configuration"}</span></div>
|
||||
<div className="flex items-start gap-4">
|
||||
<PillGuy state="dormant" className="size-14 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3"><h1 ref={heading} tabIndex={-1} className="break-words text-2xl font-semibold tracking-tight outline-none">{name || "Darnold"}</h1><Badge variant="outline">{screen === "saved" ? "Configured" : "Setup in progress"}</Badge></div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground"><AdapterMark adapter={brandAdapter} /><span>{getAdapterDisplay(brandAdapter).label}</span>{isRunner && <Badge variant="outline">{runnerProvider === "Codex (app server)" ? "Native app server runner" : runnerProvider === "Claude (ACPX)" ? "ACPX runner" : "Paperclip Runner"}</Badge>}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
<nav aria-label="Agent setup steps" className="shrink-0 md:w-44">
|
||||
<ol className="flex flex-wrap gap-2 md:flex-col">{steps.map((step, index) => <li key={step.screen} className="flex-1">
|
||||
<button type="button" aria-current={index === currentStep ? "step" : undefined} disabled={index > currentStep || testState === "running"}
|
||||
onClick={() => go(step.screen)} className={cn("flex w-full items-center gap-3 rounded-md px-3 py-3 text-left text-sm disabled:cursor-default", index === currentStep ? "bg-accent font-medium" : "text-muted-foreground")}>
|
||||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-xs", index === currentStep ? "border-foreground bg-foreground text-background" : "border-border")}>{index + 1}</span>
|
||||
{step.label}
|
||||
</button>
|
||||
</li>)}</ol>
|
||||
</nav>
|
||||
<div className="min-w-0 flex-1">
|
||||
<AnimatePresence mode="wait" initial={false}><motion.div key={screen} {...stepMotion}>
|
||||
{screen === "connect" && connectionProvider ? <OnboardingCard className="mx-auto">
|
||||
<div className="mb-8"><OnboardingHeading title="Connect a model" lede={`Connect ${name || "Darnold"} to ${connectionProvider}.`} center /></div>
|
||||
<ProviderConnectionPreview provider={connectionProvider} initialMethod={connectedMethod ?? initialConnectionMethod} initialWaiting={initialConnectionWaiting}
|
||||
onConnected={method => { resetTest(); setConnections(previous => ({ ...previous, [connectionKey]: method })); go("runtime"); }} />
|
||||
</OnboardingCard> : screen === "saved" ? <div className="flex max-w-2xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 rounded-lg border border-border p-6">
|
||||
<div className="flex items-center gap-3"><Check className="size-5" /><h2 className="text-lg font-semibold">Your agent is ready</h2></div>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<dt className="text-muted-foreground">Adapter</dt><dd>{display.label}</dd>
|
||||
{selected !== "cursor_cloud" && <><dt className="text-muted-foreground">Model</dt><dd className="break-all font-mono text-xs">{draft.model || "Default"}</dd></>}
|
||||
{isRunner && <><dt className="text-muted-foreground">Provider</dt><dd>{runnerProvider}</dd></>}
|
||||
{connectionProvider && <><dt className="text-muted-foreground">Connection</dt><dd>{connectionProvider} · {connectedMethod === "api" ? "API key" : connectedMethod === "subscription" ? "Subscription" : "Not connected"}</dd></>}
|
||||
<dt className="text-muted-foreground">Environment</dt><dd>{selected === "cursor_cloud" ? "Cursor Cloud" : environment}</dd>
|
||||
</dl>
|
||||
<p className="text-sm text-muted-foreground">Your agent has not started running.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button variant="outline" onClick={() => go("runtime")}><Settings2 className="size-4" />Edit configuration</Button>
|
||||
<Button onClick={assignTask}>Assign {name || "Darnold"} a Task<ArrowRight className="size-4" /></Button>
|
||||
</div>
|
||||
{taskCreated && <p role="status" className="text-sm text-muted-foreground">Task created: {taskCreated}</p>}
|
||||
</div> : <form className="flex min-w-0 flex-col gap-8" onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (testState !== "running" && testState !== "fail" && (!connectionProvider || connectedMethod) && validateRuntime()) go("saved");
|
||||
}}>
|
||||
<h2 className="text-xl font-semibold">Configure your agent</h2>
|
||||
<fieldset disabled={testState === "running"} className="flex min-w-0 flex-col gap-8">
|
||||
<Section title="Runtime">
|
||||
{selected === "cursor_cloud" && <div className="grid gap-5 sm:grid-cols-2">
|
||||
<TextField label="GitHub repository" required value={draft.repoUrl} onChange={repoUrl => setDraft({ repoUrl })} placeholder="https://github.com/your-org/your-repo" />
|
||||
<TextField label="Branch" value={draft.branch} onChange={branch => setDraft({ branch })} placeholder="Repository default" />
|
||||
</div>}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{selected !== "cursor_cloud" && !(selected === "kimi_local" && draft.apiKey) && <div className="flex flex-col gap-2">
|
||||
<ModelDropdown models={models} value={draft.model} onChange={model => setDraft({ model })}
|
||||
open={modelOpen} onOpenChange={setModelOpen} allowDefault required={false} groupByProvider={usesOpenCode}
|
||||
creatable defaultLabel="Default" />
|
||||
</div>}
|
||||
{!isRunner && setupEfforts(selected, draft.model).length > 0 && <SelectField label="Thinking effort" value={draft.effort} onChange={effort => setDraft({ effort })} options={["Auto", ...setupEfforts(selected, draft.model)]} />}
|
||||
</div>
|
||||
{SETUP_LOGIN_HINTS[selected] && <p className="text-sm text-muted-foreground">{SETUP_LOGIN_HINTS[selected]}</p>}
|
||||
{SETUP_CREDENTIAL_KEYS[selected] && <>
|
||||
<TextField type="password" label={SETUP_CREDENTIAL_KEYS[selected]} required={selected === "cursor_cloud"} value={draft.apiKey} onChange={apiKey => setDraft({ apiKey })} />
|
||||
{selected === "cursor_cloud" && <a href="https://cursor.com/dashboard/api?section=user-keys#user-api-keys" target="_blank" rel="noopener noreferrer" className="shrink-0 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground">get api key</a>}
|
||||
<p className="text-xs text-muted-foreground">New keys are saved as organization secrets when you finish setup.</p>
|
||||
</>}
|
||||
{selected === "kimi_local" && draft.apiKey && <TextField label="Kimi API model name" value={draft.kimiModel} onChange={kimiModel => setDraft({ kimiModel })} placeholder="kimi-for-coding" required />}
|
||||
</Section>
|
||||
{selected !== "cursor_cloud" && <Section title="Environment">
|
||||
<SelectField label="Environment" hideLabel value={environment} onChange={value => { setEnvironments(previous => ({ ...previous, [selected]: value })); resetTest(); }} options={["Organization default", "Paperclip Computer", "Local machine"]} />
|
||||
</Section>}
|
||||
</fieldset>
|
||||
<RuntimeTestCard state={testState} result={testResult} error={error} onTest={() => void runTest()} />
|
||||
{error && testState !== "fail" && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-between border-t border-border py-5">
|
||||
{connectionProvider ? <Button type="button" variant="ghost" disabled={testState === "running"} onClick={() => go("connect")}><ArrowLeft className="size-4" />Connection</Button> : <span />}
|
||||
<Button type="submit" disabled={testState === "running" || testState === "fail" || !!connectionProvider && !connectedMethod}>Finish setup<Check className="size-4" /></Button>
|
||||
</div>
|
||||
</form>}
|
||||
</motion.div></AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
<NewIssueDialog />
|
||||
</div>
|
||||
</MotionConfig>;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OnboardingCardField, OnboardingLoginCard, OnboardingLoginCodeRow } from "@/components/AdapterLoginChrome";
|
||||
import { ModelSourceTiles } from "@/components/onboarding/ModelSourceTiles";
|
||||
import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink";
|
||||
import { FooterNav } from "@/components/onboarding/FooterNav";
|
||||
import { CARD_ENTER, CARD_EXIT, CONNECTED_HOLD_MS, MAKE_ROOM, MAKE_ROOM_MS, SOURCE_COLLAPSE_MS, SOURCE_LINK_EXIT, beatDelay } from "@/components/onboarding/onboarding-motion";
|
||||
|
||||
export type ConnectionMethod = "subscription" | "api";
|
||||
export type ConnectionProvider = "Claude" | "OpenAI";
|
||||
type Phase = "idle" | "collapsing" | "opening" | "ready" | "waiting" | "connecting";
|
||||
|
||||
/** The shipped onboarding presentation and choreography, with a local provider
|
||||
* simulator. Example keys/codes never leave React state or reach storage. */
|
||||
export function ProviderConnectionPreview({ provider, initialMethod = "subscription", initialWaiting = false, onConnected }: {
|
||||
provider: ConnectionProvider;
|
||||
initialMethod?: ConnectionMethod;
|
||||
initialWaiting?: boolean;
|
||||
onConnected: (method: ConnectionMethod) => void;
|
||||
}) {
|
||||
const [method, setMethod] = useState<ConnectionMethod>(initialMethod);
|
||||
const [phase, setPhase] = useState<Phase>(initialWaiting ? "waiting" : initialMethod === "api" ? "ready" : "idle");
|
||||
const [value, setValue] = useState("");
|
||||
const pasted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!pasted.current || !value.trim() || method !== "subscription") return;
|
||||
pasted.current = false;
|
||||
setValue(""); setPhase("connecting");
|
||||
}, [value, method]);
|
||||
useEffect(() => {
|
||||
const next = phase === "collapsing" ? "opening" : phase === "opening" ? "ready" : null;
|
||||
if (!next && phase !== "connecting") return;
|
||||
const duration = phase === "collapsing" ? SOURCE_COLLAPSE_MS : phase === "opening" ? MAKE_ROOM_MS : CONNECTED_HOLD_MS;
|
||||
const timer = window.setTimeout(() => next ? setPhase(next) : onConnected(method), beatDelay(duration));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [phase, method, onConnected]);
|
||||
const finish = () => {
|
||||
if ((method === "api" || provider === "Claude") && !value.trim()) return;
|
||||
setValue(""); setPhase("connecting");
|
||||
};
|
||||
const cancel = () => { setValue(""); setPhase("idle"); };
|
||||
const cardSpace = phase !== "idle" && phase !== "collapsing";
|
||||
const live = cardSpace && phase !== "opening";
|
||||
const connecting = phase === "connecting";
|
||||
return <div>
|
||||
<ModelSourceTiles label="Connect your model provider" sources={[{
|
||||
id: provider, label: provider,
|
||||
icon: <img src={provider === "Claude" ? "/brands/claude-color.svg" : "/brands/codex-color.svg"} alt="" className="size-6" />,
|
||||
}]} mode={method} selectedId={phase === "idle" ? null : provider} collapsed={phase !== "idle"}
|
||||
onSelect={() => { if (phase === "idle") setPhase("collapsing"); }} />
|
||||
<motion.div className="overflow-hidden" inert={phase !== "idle"} initial={false}
|
||||
animate={{ opacity: phase === "idle" ? 1 : 0, height: cardSpace ? 0 : "auto" }}
|
||||
transition={{ opacity: SOURCE_LINK_EXIT, height: MAKE_ROOM }}>
|
||||
<div className="-ml-3 mt-1"><CredentialModeLink mode={method} onChange={setMethod} /></div>
|
||||
</motion.div>
|
||||
<motion.div className="overflow-hidden" inert={!live} initial={false}
|
||||
animate={{ height: cardSpace ? "auto" : 0, opacity: live ? 1 : 0 }}
|
||||
transition={{ height: MAKE_ROOM, opacity: live ? { ...CARD_ENTER, delay: MAKE_ROOM.duration } : CARD_EXIT }}>
|
||||
{cardSpace && <div className="pt-5">
|
||||
<OnboardingLoginCard loading={!live} instruction={method === "api"
|
||||
? `Provide your ${provider} API key to connect`
|
||||
: provider === "Claude" ? <><button type="button" className="underline underline-offset-2" onClick={() => setPhase("waiting")}>Sign in to Claude</button> then come back and enter authorization code</>
|
||||
: <><button type="button" className="underline underline-offset-2" onClick={() => setPhase("waiting")}>Sign in to OpenAI</button> and enter this code</>}>
|
||||
{method === "api" || provider === "Claude"
|
||||
? <OnboardingCardField key={method} label={method === "api" ? "API key" : "Authorization code"} placeholder={method === "api" ? "Enter API key here" : "Enter authorization code"}
|
||||
masked={method === "api"} autoFocus value={value} onPaste={() => { if (method === "subscription") pasted.current = true; }} onChange={setValue} onSubmit={finish} disabled={connecting} />
|
||||
: <OnboardingLoginCodeRow code="STORY-BOOK" />}
|
||||
</OnboardingLoginCard>
|
||||
</div>}
|
||||
</motion.div>
|
||||
<FooterNav onBack={phase !== "idle" ? cancel : undefined}
|
||||
primaryLabel={connecting ? "Connecting" : phase === "idle" ? "Next" : method === "api" ? "Connect" : value.trim() ? "Connect" : phase === "waiting" ? "Waiting for code" : `Sign in to ${provider}`}
|
||||
primaryDisabled={phase === "idle" || phase === "collapsing" || phase === "opening" || (method === "api" && !value.trim()) || (phase === "waiting" && !value.trim())}
|
||||
loading={connecting} primaryIcon={connecting || phase === "waiting" && !value.trim() ? "spinner" : method === "api" ? "arrow" : "none"}
|
||||
onPrimary={() => method === "api" || value.trim() ? finish() : setPhase("waiting")} />
|
||||
{provider === "OpenAI" && method === "subscription" && phase === "waiting" &&
|
||||
<div className="mt-6 text-center"><Button variant="ghost" size="sm" onClick={finish}>Simulate completed sign-in</Button></div>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# New agent prototype and onboarding connection stories
|
||||
|
||||
Run Storybook:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 --no-open -c storybook/.storybook
|
||||
```
|
||||
|
||||
## Existing onboarding
|
||||
|
||||
**Onboarding → Agent arc** mounts the shipped `OnboardingWizard` against API
|
||||
fixtures. The four **Connect · ...** stories walk from naming the agent to the
|
||||
actual Claude/OpenAI subscription and API-key screens. Claude shows the returned
|
||||
authorization-code field; Codex/OpenAI shows its device code. They stop at the
|
||||
connection prompt for visual review. They do not authorize real provider accounts.
|
||||
|
||||
**Onboarding → Connect a model** remains the older tile/mode design preview, not
|
||||
the shipped flow. Use Agent arc to inspect current onboarding behavior.
|
||||
|
||||
## New agent
|
||||
|
||||
**Onboarding → New agent → Start here · Interactive flow** starts with naming the
|
||||
agent, selecting an adapter, and simulated creation. Claude and Codex then walk
|
||||
through Connect → Configure → Confirmation. Other adapters go straight to
|
||||
configuration. No working directory, instructions, advanced settings, role,
|
||||
permissions, or scheduling steps are shown.
|
||||
|
||||
Paperclip Runner has exactly three choices, selected before creation:
|
||||
|
||||
- Codex (app server): the native Codex runner.
|
||||
- Claude (ACPX): the Claude runner.
|
||||
- OpenCode: the OpenCode runner.
|
||||
|
||||
The adapter and runner stay fixed after creation. Configuration carries the
|
||||
provider's brand mark and uses the shipped `ModelDropdown` with the adapters'
|
||||
existing model lists. Every model starts on **Default**. Adapters without a
|
||||
bundled list show Default until runtime discovery is wired up. Every model picker
|
||||
also accepts a custom model ID through the shipped dropdown’s creatable option.
|
||||
|
||||
Claude and Codex offer subscription or API-key connections, directly and through
|
||||
their runners. Eight **Connect · ...** stories expose those combinations.
|
||||
**Connect · Choose subscription or API key** starts before selecting a method.
|
||||
The connection step reuses `ModelSourceTiles`, `CredentialModeLink`,
|
||||
`OnboardingLoginCard`, `OnboardingCardField`, `OnboardingLoginCodeRow`,
|
||||
`OnboardingCard`, `OnboardingHeading`, and `FooterNav`, plus the existing
|
||||
onboarding motion constants for the source collapse, card reveal, button labels,
|
||||
and connecting hold. Only the already-selected provider is offered.
|
||||
|
||||
The provider lifecycle is simulated locally. Use example keys/codes. Claude
|
||||
accepts a pasted code or Enter; Codex has a separate **Simulate completed sign-in**
|
||||
preview control. No keys or codes are persisted or sent to a provider. Completion
|
||||
advances to configuration. Direct configuration and confirmation stories seed a
|
||||
completed connection so each screen can be reviewed independently.
|
||||
|
||||
**06 · Confirmation** and **Confirmation · Native Codex runner** expose the final
|
||||
screen directly. Finish setup reaches that screen interactively; Edit configuration
|
||||
preserves the choices, and Start over clears them.
|
||||
|
||||
These stories remain isolated design fixtures. The production implementation now
|
||||
lives in `ui/src/components/new-agent/`, with real hiring, subscription login,
|
||||
secret storage, provider probes, and task assignment. OpenCode and Pi support
|
||||
provider environment variables, including saved OpenRouter secrets. The real
|
||||
configuration page uses the same compact test card and shared settings styles.
|
||||
See `docs/specs/agent-config-ui.md` for the current workflow. Environment choices
|
||||
in Storybook remain fixtures.
|
||||
|
||||
## Configuration checks and task assignment
|
||||
|
||||
The numbered sidebar shows Connect (where supported), Configure, and Confirmation.
|
||||
Model and thinking-effort labels use the same `Field` component; their controls
|
||||
share padding, line height, and border sizing. Environment has one section label.
|
||||
|
||||
**Test** invokes the existing `agentsApi.testEnvironment` client with the selected
|
||||
adapter, model, effort, runner provider, and environment. Story-scoped fetch
|
||||
fixtures return the configured result after a short delay, and `RuntimeTestCard` presents the result in a shared neutral surface. Each state
|
||||
has one status icon, a short summary, and an action in the same position.
|
||||
Individual checks and remediation hints remain available under **Test details**. These are simulated runtime probes;
|
||||
no provider process runs. The **Test · Succeeded**, **Failed**, **Running**, and
|
||||
**Retry after failure** stories expose each state. Storybook controls choose the
|
||||
outcome and latency. A failed test prevents finishing until settings change or a
|
||||
retry succeeds. Changing settings clears stale results; leaving the story or
|
||||
starting over invalidates pending responses.
|
||||
|
||||
**Assign <agentName> a Task** opens the shipped `NewIssueDialog` with this story’s
|
||||
agent ID as assignee. A scoped agent fixture provides the matching display name.
|
||||
Task submission is also intercepted locally and displays a confirmation without
|
||||
persisting a real issue. Fixture overrides and the temporary agent cache entry
|
||||
are cleaned up on unmount.
|
||||
|
||||
|
||||
## Full agent configuration review
|
||||
|
||||
**Agents / Configuration refresh** covers Overview, Instructions, Skills,
|
||||
Harness / Runtime, Secrets & variables, Tools, Permissions / Trust, API Keys, and Revisions.
|
||||
Additional runtime stories cover Codex, OpenCode, Pi, and Paperclip Runner, plus
|
||||
interactive test-failure and save-failure fixtures.
|
||||
|
||||
This preview composes the existing `AgentContextualSidebar`, `AgentOverview`,
|
||||
`PromptsTab`, `AgentSkillsTab`, `AgentConfigForm`, `AgentToolsTab`, permission
|
||||
controls, key management, revision history, and `AgentActionButtons`. The file
|
||||
editor and skills interactions are unchanged. Runtime sections are ordered by
|
||||
optional form presentation props and shared scoped styles for spacing and
|
||||
aligned fields. The real agent configuration pages now use this same presentation.
|
||||
|
||||
Mutable fixtures support configuration saving, file editing/creation/deletion,
|
||||
skill changes, secret bindings, tool installation, permission changes, key
|
||||
creation/revocation, pause/resume, and revision restoration. Runtime tests use the
|
||||
real form action and API client with a simulated response. Saves and remote
|
||||
operations do not reach a live server. Linked audit, tool-management, and task
|
||||
pages are outside this configuration preview; unhandled mutations show an
|
||||
explicit fixture error. Switch stories to reset the example data.
|
||||
|
||||
The configuration header uses the onboarding `PillGuy` avatar and omits the manual
|
||||
heartbeat action and status badge. The unified **Secrets & variables** tab uses a
|
||||
single `AgentConfigForm` draft for environment bindings and API-access grants, so
|
||||
Save and Discard apply to both together. Variable bindings no longer appear on
|
||||
Harness / Runtime. Routine next-run notices are omitted in this review layout.
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { RuntimeTestCard } from "@/components/RuntimeTestCard";
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Star } from "lucide-react";
|
||||
import {
|
||||
AgentOverview,
|
||||
PromptsTab,
|
||||
ConfigurationTab,
|
||||
KeysTab,
|
||||
AgentRevisionsTab,
|
||||
confirmAgentConfigNavigation,
|
||||
} from "@/pages/AgentDetail";
|
||||
import { AgentSkillsTab } from "@/pages/agent-skills/AgentSkillsTab";
|
||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||
import { AgentContextualSidebar } from "@/components/AgentContextualSidebar";
|
||||
import { AgentActionButtons } from "@/components/AgentActionButtons";
|
||||
import { PillGuy } from "@/components/onboarding/PillGuy";
|
||||
import {
|
||||
AgentConfigForm,
|
||||
AdapterLoginPanel,
|
||||
} from "@/components/AgentConfigForm";
|
||||
import { NewIssueDialog } from "@/components/NewIssueDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
AGENT_DETAIL_NAVIGATION,
|
||||
parseAgentDetailView,
|
||||
type AgentLocalDetailView,
|
||||
} from "@/pages/agent-detail-navigation";
|
||||
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RuntimeTestCard } from "../RuntimeTestCard";
|
||||
import { type TestOutcome } from "../new-agent-fixtures";
|
||||
import { COMPANY, ID, REF, createSettingsFixtures, library } from "./fixtures";
|
||||
import { storybookIssues } from "../../fixtures/paperclipData";
|
||||
import "./settings.css";
|
||||
|
||||
const descriptions: Record<AgentLocalDetailView, string> = {
|
||||
overview: "A snapshot of Nova’s work, capabilities, and current setup.",
|
||||
instructions: "The files that guide how your agent thinks and works.",
|
||||
skills: "Choose the skills your agent brings to each task.",
|
||||
runtime:
|
||||
"Configure the harness, model, environment, and how your agent runs.",
|
||||
secrets:
|
||||
"Manage environment variables, secret bindings, and credentials your agent can access.",
|
||||
tools: "Manage installed connections, available tools, and access policies.",
|
||||
permissions: "Set the agent’s trust level, authority, and boundaries.",
|
||||
"api-keys": "Manage the keys this agent uses to authenticate with Paperclip.",
|
||||
revisions:
|
||||
"Review past configuration changes and restore an earlier version.",
|
||||
};
|
||||
type Feedback = Parameters<
|
||||
NonNullable<ComponentProps<typeof AgentConfigForm>["onTestFeedbackChange"]>
|
||||
>[0];
|
||||
|
||||
export function AgentSettingsPreview({
|
||||
initialTab = "overview",
|
||||
adapterType = "claude_local",
|
||||
testOutcome = "pass",
|
||||
saveFails = false,
|
||||
}: {
|
||||
initialTab?: AgentLocalDetailView;
|
||||
adapterType?: string;
|
||||
testOutcome?: TestOutcome;
|
||||
saveFails?: boolean;
|
||||
}) {
|
||||
const [fixtures] = useState(() =>
|
||||
createSettingsFixtures(adapterType, testOutcome, saveFails),
|
||||
);
|
||||
const [ready, setReady] = useState(false);
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
const uninstall = fixtures.install();
|
||||
const fixtureQueryKeys = [
|
||||
queryKeys.agents.detail(ID),
|
||||
queryKeys.agents.detail(REF),
|
||||
queryKeys.agents.skills(ID),
|
||||
queryKeys.companySkills.list(COMPANY),
|
||||
queryKeys.agents.keys(ID),
|
||||
queryKeys.agents.configRevisions(ID),
|
||||
queryKeys.instance.experimentalSettings,
|
||||
queryKeys.instance.generalSettings,
|
||||
queryKeys.environments.list(COMPANY),
|
||||
queryKeys.secrets.list(COMPANY),
|
||||
["tools", COMPANY],
|
||||
["tools", "connection", "settings-github"],
|
||||
];
|
||||
const clearFixtureQueries = () =>
|
||||
fixtureQueryKeys.forEach((queryKey) =>
|
||||
queryClient.removeQueries({ queryKey }),
|
||||
);
|
||||
clearFixtureQueries();
|
||||
queryClient.setQueryData(queryKeys.agents.detail(ID), fixtures.agent);
|
||||
setSelectedCompanyId(COMPANY);
|
||||
navigate(`/PAP/agents/${REF}/${initialTab}`, { replace: true });
|
||||
setReady(true);
|
||||
return () => {
|
||||
uninstall();
|
||||
clearFixtureQueries();
|
||||
};
|
||||
}, [fixtures, initialTab, queryClient, setSelectedCompanyId]);
|
||||
if (!ready || selectedCompanyId !== COMPANY) return null;
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:companyPrefix/agents/:agentId/:tab?"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="p-8 space-y-4">
|
||||
<h2 className="text-xl font-semibold">Linked page</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This preview focuses on agent configuration. The existing
|
||||
destination is unchanged.
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/PAP/agents/${REF}/overview`)}>
|
||||
Back to agent
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const location = useLocation();
|
||||
const view = parseAgentDetailView(
|
||||
location.pathname.split("/").pop() ?? "overview",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: agent } = useQuery({
|
||||
queryKey: queryKeys.agents.detail(ID),
|
||||
queryFn: () => agentsApi.get(ID, COMPANY),
|
||||
});
|
||||
const { data: skills } = useQuery({
|
||||
queryKey: queryKeys.agents.skills(ID),
|
||||
queryFn: () => agentsApi.skills(ID, COMPANY),
|
||||
enabled: view === "overview",
|
||||
});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [starred, setStarred] = useState(false);
|
||||
const save = useRef<(() => void) | null>(null),
|
||||
cancel = useRef<(() => void) | null>(null),
|
||||
test = useRef<(() => void) | null>(null);
|
||||
const onSaveAction = useCallback((fn: (() => void) | null) => {
|
||||
save.current = fn;
|
||||
}, []);
|
||||
const onCancelAction = useCallback((fn: (() => void) | null) => {
|
||||
cancel.current = fn;
|
||||
}, []);
|
||||
const onTestAction = useCallback((fn: (() => void) | null) => {
|
||||
test.current = fn;
|
||||
}, []);
|
||||
const [testAction, setTestAction] = useState({
|
||||
disabled: false,
|
||||
pending: false,
|
||||
});
|
||||
const [feedback, setFeedback] = useState<Feedback>({
|
||||
errorMessage: null,
|
||||
result: null,
|
||||
login: null,
|
||||
});
|
||||
const onDirty = useCallback((value: boolean) => {
|
||||
setDirty(value);
|
||||
if (value) setSaved(false);
|
||||
}, []);
|
||||
const update = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) =>
|
||||
agentsApi.update(ID, patch, COMPANY),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(queryKeys.agents.detail(ID), updated);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.configRevisions(ID),
|
||||
});
|
||||
},
|
||||
});
|
||||
const permissions = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof agentsApi.updatePermissions>[1]) =>
|
||||
agentsApi.updatePermissions(ID, patch, COMPANY),
|
||||
onSuccess: (updated) =>
|
||||
queryClient.setQueryData(queryKeys.agents.detail(ID), updated),
|
||||
});
|
||||
useEffect(() => {
|
||||
setDirty(false);
|
||||
setSaved(false);
|
||||
setSaveError(null);
|
||||
}, [view]);
|
||||
if (!agent)
|
||||
return (
|
||||
<div className="p-8 text-sm text-muted-foreground">Loading agent…</div>
|
||||
);
|
||||
const title =
|
||||
view === "secrets"
|
||||
? "Secrets & variables"
|
||||
: AGENT_DETAIL_NAVIGATION.flatMap((s) => s.items).find(
|
||||
(t) => t.value === view,
|
||||
)?.label;
|
||||
const display = getAdapterDisplay(agent.adapterType);
|
||||
const Icon = display.icon;
|
||||
const callbacks = {
|
||||
onDirtyChange: onDirty,
|
||||
onSaveActionChange: onSaveAction,
|
||||
onCancelActionChange: onCancelAction,
|
||||
onSavingChange: setSaving,
|
||||
};
|
||||
async function saveAgent(patch: Record<string, unknown>) {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await update.mutateAsync(patch);
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof Error ? error.message : "Could not save changes.",
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-3 text-xs text-muted-foreground">
|
||||
<span>Agents</span>
|
||||
<ChevronRight className="size-3" />
|
||||
<span>{agent.name}</span>
|
||||
<ChevronRight className="size-3" />
|
||||
<span className="text-foreground">{title}</span>
|
||||
</div>
|
||||
<div className="flex min-h-screen">
|
||||
<div
|
||||
className="w-52 shrink-0"
|
||||
onClickCapture={(event) => {
|
||||
if (
|
||||
dirty &&
|
||||
(event.target as HTMLElement).closest("a") &&
|
||||
!confirmAgentConfigNavigation(true)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AgentContextualSidebar
|
||||
agentRef={REF}
|
||||
agentId={ID}
|
||||
agentName={agent.name}
|
||||
labels={{ secrets: "Secrets & variables" }}
|
||||
/>
|
||||
</div>
|
||||
<main className="min-w-0 flex-1 px-6 py-8 lg:px-10">
|
||||
<div className="mx-auto max-w-5xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${agent.name} avatar`}
|
||||
className="shrink-0"
|
||||
>
|
||||
<PillGuy state="alive" className="size-12" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{agent.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{agent.adapterType === "claude_local" ||
|
||||
agent.adapterType === "codex_local" ? (
|
||||
<img
|
||||
src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
|
||||
className="size-4"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span>{display.label}</span>
|
||||
<span>·</span>
|
||||
<span>{agent.title || agent.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={starred ? "Unstar agent" : "Star agent"}
|
||||
onClick={() => setStarred(!starred)}
|
||||
>
|
||||
<Star className={cn("size-4", starred && "fill-current")} />
|
||||
</Button>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={COMPANY}
|
||||
assignLabel="Assign Task"
|
||||
showRun={false}
|
||||
showStatus={false}
|
||||
onActionError={setSaveError}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{descriptions[view]}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn("agent-settings-content", `agent-settings-${view}`)}
|
||||
key={view}
|
||||
>
|
||||
{view === "overview" && (
|
||||
<AgentOverview
|
||||
agent={agent}
|
||||
runs={[]}
|
||||
assignedIssues={storybookIssues.slice(0, 3)}
|
||||
directReportCount={0}
|
||||
skillNames={library
|
||||
.filter((s) => skills?.desiredSkills.includes(s.key))
|
||||
.map((s) => s.name)}
|
||||
agentRouteId={REF}
|
||||
/>
|
||||
)}
|
||||
{view === "instructions" && (
|
||||
<PromptsTab
|
||||
agent={agent}
|
||||
companyId={COMPANY}
|
||||
showSaveNotice={false}
|
||||
{...callbacks}
|
||||
/>
|
||||
)}
|
||||
{view === "skills" && (
|
||||
<AgentSkillsTab agent={agent} companyId={COMPANY} />
|
||||
)}
|
||||
{(view === "runtime" || view === "secrets") && (
|
||||
<div className="space-y-6">
|
||||
<div className="agent-settings-form">
|
||||
<AgentConfigForm
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
onSave={saveAgent}
|
||||
isSaving={saving}
|
||||
hideInlineSave
|
||||
hidePromptTemplate
|
||||
hideInstructionsFile
|
||||
content={view === "runtime" ? "configuration" : "secrets"}
|
||||
environmentVariablesPlacement="secrets"
|
||||
sectionLayout="cards"
|
||||
canConfigureProviderTrace
|
||||
sectionOrder={[
|
||||
"adapter",
|
||||
"permissions",
|
||||
"environment",
|
||||
"run-policy",
|
||||
"identity",
|
||||
]}
|
||||
sectionTitles={{
|
||||
adapter: "Harness",
|
||||
permissions: "Model & execution",
|
||||
identity: "Agent identity",
|
||||
}}
|
||||
onDirtyChange={onDirty}
|
||||
onSaveActionChange={onSaveAction}
|
||||
onCancelActionChange={onCancelAction}
|
||||
onTestActionChange={onTestAction}
|
||||
onTestActionStateChange={setTestAction}
|
||||
onTestFeedbackChange={setFeedback}
|
||||
/>
|
||||
</div>
|
||||
{view === "runtime" && (
|
||||
<>
|
||||
<RuntimeTestCard
|
||||
state={
|
||||
testAction.pending
|
||||
? "running"
|
||||
: feedback.errorMessage ||
|
||||
feedback.result?.status === "fail"
|
||||
? "fail"
|
||||
: feedback.result?.status === "warn"
|
||||
? "warn"
|
||||
: feedback.result
|
||||
? "pass"
|
||||
: "idle"
|
||||
}
|
||||
result={feedback.result}
|
||||
error={feedback.errorMessage}
|
||||
disabled={testAction.disabled}
|
||||
onTest={() => test.current?.()}
|
||||
/>
|
||||
{feedback.login && (
|
||||
<AdapterLoginPanel
|
||||
{...feedback.login}
|
||||
onStored={() => test.current?.()}
|
||||
onApplyStored={() => test.current?.()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{view === "permissions" && (
|
||||
<ConfigurationTab
|
||||
agent={agent}
|
||||
companyId={COMPANY}
|
||||
{...callbacks}
|
||||
updatePermissions={permissions}
|
||||
content="permissions"
|
||||
/>
|
||||
)}
|
||||
{view === "tools" && (
|
||||
<AgentToolsTab agent={agent} companyId={COMPANY} />
|
||||
)}
|
||||
{view === "api-keys" && (
|
||||
<KeysTab agentId={ID} companyId={COMPANY} />
|
||||
)}
|
||||
{view === "revisions" && (
|
||||
<AgentRevisionsTab agent={agent} companyId={COMPANY} />
|
||||
)}
|
||||
</div>
|
||||
{(saveError || permissions.error) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{saveError ?? String(permissions.error)}
|
||||
</p>
|
||||
)}
|
||||
{(["runtime", "secrets", "instructions"] as string[]).includes(
|
||||
view,
|
||||
) && (
|
||||
<footer className="sticky bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{saving
|
||||
? "Saving changes…"
|
||||
: dirty
|
||||
? "You have unsaved changes."
|
||||
: saved
|
||||
? "Changes saved."
|
||||
: ""}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => cancel.current?.()}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => {
|
||||
Promise.resolve(save.current?.()).catch(() => {});
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<NewIssueDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
import type {
|
||||
AgentDetail,
|
||||
AgentInstructionsBundle,
|
||||
AgentSkillSnapshot,
|
||||
CompanySkillListItem,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
storybookHiredAgent,
|
||||
storybookAgents,
|
||||
storybookIssues,
|
||||
storybookSecrets,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import { models as claudeModels } from "@paperclipai/adapter-claude-local";
|
||||
import { models as openCodeModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { storybookEnvironments } from "../../fixtures/onboardingEnvironment";
|
||||
import { models as codexModels } from "@paperclipai/adapter-codex-local";
|
||||
import { runtimeTestResult, type TestOutcome } from "../new-agent-fixtures";
|
||||
export const COMPANY = "company-storybook";
|
||||
export const ID = "agent-settings-preview";
|
||||
export const REF = "nova";
|
||||
|
||||
export const library = [
|
||||
[
|
||||
"paperclip",
|
||||
"Paperclip",
|
||||
"Coordinate tasks, report progress, and work with your team.",
|
||||
],
|
||||
[
|
||||
"design-guide",
|
||||
"Design guide",
|
||||
"Build consistent interfaces using the product’s design system.",
|
||||
],
|
||||
[
|
||||
"browser-testing",
|
||||
"Browser testing",
|
||||
"Verify user journeys and catch visual regressions.",
|
||||
],
|
||||
[
|
||||
"release-checklist",
|
||||
"Release checklist",
|
||||
"Prepare changes for review and verify release readiness.",
|
||||
],
|
||||
].map(([key, name, description]) => ({
|
||||
id: `settings-skill-${key}`,
|
||||
companyId: COMPANY,
|
||||
key,
|
||||
slug: key,
|
||||
name,
|
||||
description,
|
||||
sourceType: "local_path",
|
||||
sourceLocator: `skills/${key}`,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attachedAgentCount: 1,
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Organization library",
|
||||
sourceBadge: "local",
|
||||
sourcePath: `skills/${key}`,
|
||||
catalogKind: null,
|
||||
originHash: null,
|
||||
packageName: null,
|
||||
packageVersion: null,
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
tagline: null,
|
||||
authorName: null,
|
||||
homepageUrl: null,
|
||||
categories: [],
|
||||
sharingScope: "company",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: null,
|
||||
forkedFromCompanyId: null,
|
||||
starCount: 0,
|
||||
installCount: 1,
|
||||
forkCount: 0,
|
||||
currentVersionId: null,
|
||||
})) as CompanySkillListItem[];
|
||||
|
||||
export function createSettingsFixtures(
|
||||
adapterType = "claude_local",
|
||||
outcome: TestOutcome = "pass",
|
||||
saveFails = false,
|
||||
) {
|
||||
let agent: AgentDetail = {
|
||||
...storybookHiredAgent,
|
||||
id: ID,
|
||||
urlKey: REF,
|
||||
name: "Nova",
|
||||
title: "Product engineer",
|
||||
role: "engineer",
|
||||
capabilities:
|
||||
"Build thoughtful product interfaces, investigate bugs, and verify changes in the browser.",
|
||||
adapterType: adapterType as AgentDetail["adapterType"],
|
||||
adapterConfig: {
|
||||
"access.MODEL_API": { type: "secret_ref", secretId: "secret-openai" },
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 3600,
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: { canCreateAgents: false, canCreateSkills: true },
|
||||
chainOfCommand: [],
|
||||
access: {
|
||||
canAssignTasks: true,
|
||||
taskAssignSource: "explicit_grant",
|
||||
membership: null,
|
||||
grants: [],
|
||||
},
|
||||
};
|
||||
let desiredSkills = ["paperclip", "design-guide"];
|
||||
const files: Record<string, string> = {
|
||||
"AGENTS.md":
|
||||
"# Nova\n\nYou are a product engineer. Build interfaces that are clear, useful, and reliable.\n\n## Working style\n\n- Read the task and relevant code before making changes.\n- Keep changes focused and preserve existing behavior.\n- Test the experience in the browser before handing it back.\n\nRead [workflow.md](workflow.md) for your delivery checklist.\n",
|
||||
"workflow.md":
|
||||
"# Delivery checklist\n\n1. Understand the user’s goal.\n2. Make a focused change.\n3. Verify the result.\n4. Explain what changed and how it was tested.\n",
|
||||
"references/product.md":
|
||||
"# Product context\n\nPaperclip helps people coordinate teams of agents. Prefer simple workflows with visible progress.\n",
|
||||
};
|
||||
let bundleMode: "managed" | "external" = "managed";
|
||||
let entryFile = "AGENTS.md";
|
||||
const bundle = (): AgentInstructionsBundle => ({
|
||||
agentId: ID,
|
||||
companyId: COMPANY,
|
||||
mode: bundleMode,
|
||||
rootPath: `/managed/agents/${ID}`,
|
||||
managedRootPath: `/managed/agents/${ID}`,
|
||||
entryFile,
|
||||
resolvedEntryPath: `/managed/agents/${ID}/${entryFile}`,
|
||||
editable: true,
|
||||
warnings: [],
|
||||
legacyPromptTemplateActive: false,
|
||||
legacyBootstrapPromptTemplateActive: false,
|
||||
files: Object.entries(files).map(([path, content]) => ({
|
||||
path,
|
||||
size: content.length,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: path === entryFile,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
})),
|
||||
});
|
||||
const skills = (): AgentSkillSnapshot => ({
|
||||
adapterType: agent.adapterType,
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills,
|
||||
warnings: [],
|
||||
entries: desiredSkills.map((key) => ({
|
||||
key,
|
||||
runtimeName: key,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "configured",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: `skills/${key}`,
|
||||
targetPath: null,
|
||||
detail: "Available on the next run.",
|
||||
})),
|
||||
});
|
||||
let secrets = [...storybookSecrets];
|
||||
let keys = [
|
||||
{
|
||||
id: "settings-key-1",
|
||||
name: "Automation",
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: null as string | null,
|
||||
},
|
||||
];
|
||||
let revisions = [
|
||||
{
|
||||
id: "71c4f91a-initial",
|
||||
companyId: COMPANY,
|
||||
agentId: ID,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
source: "board",
|
||||
rolledBackFromRevisionId: null,
|
||||
changedKeys: ["adapterConfig", "runtimeConfig"],
|
||||
beforeConfig: {},
|
||||
afterConfig: { ...agent },
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
let installs = [{ targetType: "agent", targetId: ID }];
|
||||
const connection = () => ({
|
||||
id: "settings-github",
|
||||
companyId: COMPANY,
|
||||
name: "GitHub",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
transportType: "http",
|
||||
config: { sourceTemplateKey: "github" },
|
||||
transportConfig: { sourceTemplateKey: "github" },
|
||||
installs,
|
||||
});
|
||||
const catalog = [
|
||||
{
|
||||
id: "tool-read",
|
||||
connectionId: "settings-github",
|
||||
toolName: "get_repository",
|
||||
displayName: "Read repository",
|
||||
description: "Read repository contents and pull requests.",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
riskLevel: "low",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "tool-pr",
|
||||
connectionId: "settings-github",
|
||||
toolName: "create_pull_request",
|
||||
displayName: "Create pull request",
|
||||
description: "Open a pull request for review.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: false,
|
||||
riskLevel: "medium",
|
||||
status: "active",
|
||||
},
|
||||
];
|
||||
const recordRevision = (
|
||||
before: AgentDetail,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
revisions.unshift({
|
||||
...revisions[0],
|
||||
id: crypto.randomUUID(),
|
||||
changedKeys: Object.keys(patch).filter(
|
||||
(key) =>
|
||||
JSON.stringify(
|
||||
(before as unknown as Record<string, unknown>)[key],
|
||||
) !== JSON.stringify(patch[key]),
|
||||
),
|
||||
beforeConfig: { ...before },
|
||||
afterConfig: { ...agent },
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
return {
|
||||
get agent() {
|
||||
return agent;
|
||||
},
|
||||
install() {
|
||||
const previous = window.fetch;
|
||||
const handler: typeof fetch = async (input, init) => {
|
||||
const url = new URL(
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
window.location.origin,
|
||||
);
|
||||
const method =
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET");
|
||||
const payload = async () =>
|
||||
JSON.parse(
|
||||
typeof init?.body === "string"
|
||||
? init.body
|
||||
: input instanceof Request
|
||||
? await input.clone().text()
|
||||
: "{}",
|
||||
);
|
||||
const json = (value: unknown) => Response.json(value);
|
||||
if (url.pathname === "/api/instance/settings/experimental")
|
||||
return json({
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableEnvironments: true,
|
||||
enableNativeRunner: true,
|
||||
});
|
||||
if (url.pathname === "/api/instance/settings/general")
|
||||
return json({ executionMode: "any" });
|
||||
if (url.pathname === `/api/companies/${COMPANY}/environments`)
|
||||
return json(storybookEnvironments());
|
||||
if (url.pathname === `/api/companies/${COMPANY}/agents`)
|
||||
return json([...storybookAgents, agent]);
|
||||
if (url.pathname === `/api/companies/${COMPANY}/secrets`) {
|
||||
if (method === "POST") {
|
||||
const data = await payload();
|
||||
const secret = {
|
||||
...storybookSecrets[0],
|
||||
id: `settings-secret-${secrets.length + 1}`,
|
||||
name: data.name,
|
||||
key: data.name.toLowerCase(),
|
||||
latestVersion: 1,
|
||||
};
|
||||
secrets.push(secret);
|
||||
return json(secret);
|
||||
}
|
||||
return json(secrets);
|
||||
}
|
||||
if (url.pathname === `/api/companies/${COMPANY}/skills`)
|
||||
return json(library);
|
||||
if (url.pathname.match(/\/adapters\/[^/]+\/models$/))
|
||||
return json(
|
||||
url.pathname.includes("claude")
|
||||
? claudeModels
|
||||
: url.pathname.includes("codex") ||
|
||||
url.pathname.includes("paperclip_runner")
|
||||
? codexModels
|
||||
: url.pathname.includes("opencode")
|
||||
? openCodeModels
|
||||
: [],
|
||||
);
|
||||
if (url.pathname.match(/\/adapters\/[^/]+\/test-environment$/)) {
|
||||
const data = await payload();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return json(
|
||||
runtimeTestResult(
|
||||
url.pathname.split("/").at(-2) ?? agent.adapterType,
|
||||
outcome,
|
||||
data.adapterConfig?.model ?? "",
|
||||
"Paperclip Computer",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url.pathname === `/api/companies/${COMPANY}/tools/connections`)
|
||||
return json({ connections: [connection()] });
|
||||
if (url.pathname === `/api/companies/${COMPANY}/tools/policies`)
|
||||
return json({ policies: [] });
|
||||
if (url.pathname.includes("/tools/profiles/effective/agents/"))
|
||||
return json({
|
||||
profiles: [
|
||||
{
|
||||
id: "profile-engineering",
|
||||
name: "Engineering",
|
||||
summary: { isCompanyDefault: true },
|
||||
},
|
||||
],
|
||||
entries: [{ effect: "include", connectionId: "settings-github" }],
|
||||
allowedTools: catalog,
|
||||
});
|
||||
if (url.pathname === "/api/tool-connections/settings-github/catalog")
|
||||
return json({ catalog });
|
||||
if (url.pathname === "/api/tool-connections/settings-github/grants")
|
||||
return json({ grants: [] });
|
||||
if (url.pathname === "/api/tool-connections/settings-github/installs") {
|
||||
if (method === "PUT") installs = (await payload()).installs;
|
||||
return json({ connectionId: "settings-github", installs });
|
||||
}
|
||||
if (
|
||||
url.pathname === `/api/companies/${COMPANY}/issues` &&
|
||||
method === "POST"
|
||||
) {
|
||||
const data = await payload();
|
||||
return json({
|
||||
...storybookIssues[0],
|
||||
...data,
|
||||
id: "settings-task",
|
||||
identifier: "PAP-PREVIEW",
|
||||
});
|
||||
}
|
||||
const match = url.pathname.match(
|
||||
new RegExp(`^/api/agents/(?:${ID}|${REF})(/.*)?$`),
|
||||
);
|
||||
if (match) {
|
||||
const path = match[1] ?? "";
|
||||
if (method === "PATCH" && (path === "" || path === "/permissions")) {
|
||||
if (saveFails)
|
||||
return Response.json(
|
||||
{ error: "Could not save changes. Please try again." },
|
||||
{ status: 503 },
|
||||
);
|
||||
const patch = await payload();
|
||||
const before = agent;
|
||||
agent =
|
||||
path === "/permissions"
|
||||
? {
|
||||
...agent,
|
||||
permissions: { ...agent.permissions, ...patch },
|
||||
access: {
|
||||
...agent.access!,
|
||||
canAssignTasks:
|
||||
patch.canAssignTasks ?? agent.access?.canAssignTasks,
|
||||
},
|
||||
}
|
||||
: { ...agent, ...patch };
|
||||
recordRevision(before, patch);
|
||||
return json(agent);
|
||||
}
|
||||
if (!path) return json(agent);
|
||||
if (path === "/skills/sync") {
|
||||
const data = await payload();
|
||||
desiredSkills = data.desiredSkills.map(
|
||||
(s: string | { key: string }) =>
|
||||
typeof s === "string" ? s : s.key,
|
||||
);
|
||||
return json(skills());
|
||||
}
|
||||
if (path === "/skills") return json(skills());
|
||||
if (path === "/instructions-bundle/file") {
|
||||
const path = url.searchParams.get("path") ?? "AGENTS.md";
|
||||
if (method === "DELETE") {
|
||||
delete files[path];
|
||||
return json(bundle());
|
||||
}
|
||||
const data = method === "PUT" ? await payload() : { path };
|
||||
if (method === "PUT") files[data.path] = data.content;
|
||||
return json({
|
||||
...bundle().files.find((f) => f.path === data.path),
|
||||
content: files[data.path] ?? "",
|
||||
});
|
||||
}
|
||||
if (path === "/instructions-bundle") {
|
||||
if (method === "PATCH") {
|
||||
const data = await payload();
|
||||
bundleMode = data.mode ?? bundleMode;
|
||||
entryFile = data.entryFile ?? entryFile;
|
||||
}
|
||||
return json(bundle());
|
||||
}
|
||||
if (path === "/keys") {
|
||||
if (method === "POST") {
|
||||
const data = await payload();
|
||||
const key = {
|
||||
id: `settings-key-${keys.length + 1}`,
|
||||
name: data.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: null,
|
||||
};
|
||||
keys.push(key);
|
||||
return json({
|
||||
...key,
|
||||
token: "pcp_storybook_example_key_not_a_real_credential",
|
||||
});
|
||||
}
|
||||
return json(keys);
|
||||
}
|
||||
if (path.startsWith("/keys/") && method === "DELETE") {
|
||||
keys = keys.map((k) =>
|
||||
k.id === path.split("/").pop()
|
||||
? { ...k, revokedAt: new Date().toISOString() }
|
||||
: k,
|
||||
);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (path.endsWith("/rollback")) {
|
||||
const revision = revisions.find((r) => path.includes(r.id));
|
||||
if (revision) {
|
||||
const before = agent;
|
||||
agent = { ...agent, ...revision.afterConfig };
|
||||
recordRevision(before, revision.afterConfig);
|
||||
}
|
||||
return json(agent);
|
||||
}
|
||||
if (path === "/config-revisions") return json(revisions);
|
||||
if (path === "/pause" || path === "/resume") {
|
||||
agent = { ...agent, status: path === "/pause" ? "paused" : "idle" };
|
||||
return json(agent);
|
||||
}
|
||||
if (path === "/runtime-state") return json(null);
|
||||
if (path === "/task-sessions" || path === "/heartbeat-runs")
|
||||
return json([]);
|
||||
}
|
||||
// Keep all other reads on Storybook's existing fixtures. Never send an
|
||||
// unhandled mutation outside this isolated preview.
|
||||
if (method !== "GET" && url.pathname.startsWith("/api/"))
|
||||
return Response.json(
|
||||
{
|
||||
error: "This action is not configured in this Storybook fixture.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
return previous(input, init);
|
||||
};
|
||||
window.fetch = handler;
|
||||
return () => {
|
||||
if (window.fetch === handler) window.fetch = previous;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Agent settings layout is shared with the app through src/index.css. */
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { AdapterEnvironmentTestResult, Agent } from "@paperclipai/shared";
|
||||
import { storybookAgents, storybookIssues } from "../fixtures/paperclipData";
|
||||
|
||||
export type TestOutcome = "pass" | "fail";
|
||||
export type TestState = "idle" | "running" | TestOutcome;
|
||||
export const PREVIEW_AGENT_ID = "agent-new-agent-preview";
|
||||
export const PREVIEW_COMPANY_ID = "company-storybook";
|
||||
|
||||
export function runtimeTestResult(adapterType: string, outcome: TestOutcome, model: string, environment: string): AdapterEnvironmentTestResult {
|
||||
return {
|
||||
adapterType, status: outcome, testedAt: new Date().toISOString(),
|
||||
checks: outcome === "pass" ? [
|
||||
{ code: "runtime_available", level: "info", message: `Runtime is available in ${environment}.` },
|
||||
{ code: "model_response", level: "info", message: `${model || "Default model"} responded successfully.` },
|
||||
] : [
|
||||
{ code: "model_unavailable", level: "error", message: `${model || "Default model"} could not be reached in ${environment}.`, hint: "Check the model name and provider connection, then test again." },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Mounted only by this prototype. The actual API clients and task dialog run
|
||||
* against these fixtures; no provider is invoked and no task is persisted. */
|
||||
export function useNewAgentFixtures(agent: Agent, outcome: TestOutcome, delayMs: number, onTaskCreated: (title: string) => void) {
|
||||
const current = useRef({ agent, outcome, delayMs, onTaskCreated });
|
||||
current.current = { agent, outcome, delayMs, onTaskCreated };
|
||||
useEffect(() => {
|
||||
const original = window.fetch;
|
||||
const timers = new Map<number, () => void>();
|
||||
const fixtureFetch: typeof fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
||||
const body = async () => JSON.parse(typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : "{}");
|
||||
const test = url.pathname.match(/^\/api\/companies\/company-storybook\/adapters\/([^/]+)\/test-environment$/);
|
||||
if (test && method === "POST") {
|
||||
const request = await body();
|
||||
const { outcome: result, delayMs: delay } = current.current;
|
||||
await new Promise<void>(resolve => {
|
||||
const timer = window.setTimeout(() => { timers.delete(timer); resolve(); }, delay);
|
||||
timers.set(timer, resolve);
|
||||
});
|
||||
const environment = request.environmentId === "environment-storybook-sandbox" ? "Paperclip Computer"
|
||||
: request.environmentId === "environment-storybook-local" ? "Local machine" : "Organization default";
|
||||
return Response.json(runtimeTestResult(test[1], result, request.adapterConfig?.model ?? "", environment));
|
||||
}
|
||||
if (url.pathname === `/api/companies/${PREVIEW_COMPANY_ID}/agents` && method === "GET") {
|
||||
return Response.json([...storybookAgents, current.current.agent]);
|
||||
}
|
||||
if (url.pathname === `/api/agents/${PREVIEW_AGENT_ID}` && method === "GET") return Response.json(current.current.agent);
|
||||
if (url.pathname === `/api/companies/${PREVIEW_COMPANY_ID}/issues` && method === "POST") {
|
||||
const request = await body();
|
||||
current.current.onTaskCreated(request.title);
|
||||
return Response.json({ ...storybookIssues[0], ...request, id: "issue-new-agent-preview", identifier: "PAP-PREVIEW", companyId: PREVIEW_COMPANY_ID });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
window.fetch = fixtureFetch;
|
||||
return () => {
|
||||
if (window.fetch === fixtureFetch) window.fetch = original;
|
||||
for (const [timer, resolve] of timers) { window.clearTimeout(timer); resolve(); }
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentSettingsPreview } from "../prototypes/agent-settings/AgentSettingsPreview";
|
||||
const meta = {
|
||||
title: "Agents/Configuration refresh",
|
||||
component: AgentSettingsPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Design review of the full agent settings. Uses the existing navigation and functional tab components with isolated, mutable Storybook fixtures. No production data is changed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialTab: {
|
||||
control: "select",
|
||||
options: [
|
||||
"overview",
|
||||
"instructions",
|
||||
"skills",
|
||||
"runtime",
|
||||
"secrets",
|
||||
"tools",
|
||||
"permissions",
|
||||
"api-keys",
|
||||
"revisions",
|
||||
],
|
||||
},
|
||||
adapterType: {
|
||||
control: "select",
|
||||
options: [
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"paperclip_runner",
|
||||
],
|
||||
},
|
||||
testOutcome: { control: "select", options: ["pass", "fail"] },
|
||||
},
|
||||
render: (args) => (
|
||||
<AgentSettingsPreview key={JSON.stringify(args)} {...args} />
|
||||
),
|
||||
} satisfies Meta<typeof AgentSettingsPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Overview: Story = { args: { initialTab: "overview" } };
|
||||
export const Instructions: Story = { args: { initialTab: "instructions" } };
|
||||
export const Skills: Story = { args: { initialTab: "skills" } };
|
||||
export const Runtime: Story = {
|
||||
name: "Harness / Runtime",
|
||||
args: { initialTab: "runtime" },
|
||||
};
|
||||
export const CodexRuntime: Story = {
|
||||
args: { initialTab: "runtime", adapterType: "codex_local" },
|
||||
};
|
||||
export const OpenCodeRuntime: Story = {
|
||||
args: { initialTab: "runtime", adapterType: "opencode_local" },
|
||||
};
|
||||
export const PiRuntime: Story = {
|
||||
args: { initialTab: "runtime", adapterType: "pi_local" },
|
||||
};
|
||||
export const RunnerRuntime: Story = {
|
||||
args: { initialTab: "runtime", adapterType: "paperclip_runner" },
|
||||
};
|
||||
export const Secrets: Story = {
|
||||
name: "Secrets & variables",
|
||||
args: { initialTab: "secrets" },
|
||||
};
|
||||
export const Tools: Story = { args: { initialTab: "tools" } };
|
||||
export const Permissions: Story = {
|
||||
name: "Permissions / Trust",
|
||||
args: { initialTab: "permissions" },
|
||||
};
|
||||
export const ApiKeys: Story = {
|
||||
name: "API Keys",
|
||||
args: { initialTab: "api-keys" },
|
||||
};
|
||||
export const Revisions: Story = { args: { initialTab: "revisions" } };
|
||||
export const RuntimeTestFailure: Story = {
|
||||
args: { initialTab: "runtime", testOutcome: "fail" },
|
||||
};
|
||||
export const SaveFailure: Story = {
|
||||
args: { initialTab: "runtime", saveFails: true },
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { NewAgentWizard } from "../prototypes/NewAgentWizard";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/New agent",
|
||||
component: NewAgentWizard,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: { description: { component: "Interactive design prototype: name → adapter → simulated creation → connection → configuration → confirmation. Each screen is directly inspectable below. All state is local to the story; subscription and API-key connections are simulated, and actual hiring is deferred. Use Start over to replay. The adapter and runner are fixed after creation." } },
|
||||
},
|
||||
argTypes: {
|
||||
testOutcome: { control: "select", options: ["pass", "fail"] },
|
||||
initialTestState: { control: "select", options: ["idle", "running", "pass", "fail"] },
|
||||
initialScreen: { control: "select", options: ["name", "adapter", "connect", "runtime", "saved"] },
|
||||
initialRunnerProvider: { control: "select", options: ["Codex (app server)", "Claude (ACPX)", "OpenCode"] },
|
||||
initialConnectionMethod: { control: "select", options: ["subscription", "api"] },
|
||||
initialAdapter: { control: "select", options: [null, "claude_local", "codex_local", "cursor", "cursor_cloud", "gemini_local", "grok_local", "kimi_local", "opencode_local", "pi_local", "hermes_local", "paperclip_runner"] },
|
||||
},
|
||||
// Remount when Storybook controls change; normal wizard navigation keeps drafts.
|
||||
render: args => <NewAgentWizard key={JSON.stringify(args)} {...args} />,
|
||||
} satisfies Meta<typeof NewAgentWizard>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const InteractiveFlow: Story = { name: "Start here · Interactive flow" };
|
||||
export const NameYourAgent: Story = { name: "01 · Name your agent", args: { initialName: "Darnold" } };
|
||||
export const ChooseAdapter: Story = { name: "02 · All adapters", args: { initialScreen: "adapter", initialName: "Darnold" } };
|
||||
export const CloudAdapters: Story = { args: { initialScreen: "adapter", initialName: "Darnold", cloud: true } };
|
||||
export const RunnerDisabled: Story = { args: { initialScreen: "adapter", initialName: "Darnold", nativeRunnerEnabled: false } };
|
||||
export const AdapterSelected: Story = { name: "03 · Adapter selected", args: { initialScreen: "adapter", initialName: "Darnold", initialAdapter: "opencode_local" } };
|
||||
export const OpenCodeConfiguration: Story = { name: "04 · OpenCode configuration", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "opencode_local" } };
|
||||
export const PiConfiguration: Story = { name: "05 · Pi configuration", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "pi_local" } };
|
||||
export const ClaudeConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "claude_local" } };
|
||||
export const CodexConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "codex_local" } };
|
||||
export const CursorConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "cursor" } };
|
||||
export const CursorCloudConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "cursor_cloud" } };
|
||||
export const GeminiConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "gemini_local" } };
|
||||
export const GrokBuildConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "grok_local" } };
|
||||
export const KimiConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "kimi_local" } };
|
||||
export const HermesConfiguration: Story = { args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "hermes_local" } };
|
||||
export const Confirmation: Story = { name: "06 · Confirmation", args: { initialScreen: "saved", initialName: "Darnold", initialAdapter: "opencode_local" } };
|
||||
export const NewAgentEntryPoint: Story = { name: "New agent entry point", args: { initialOpen: false } };
|
||||
export const MobileAdapterPicker: Story = { name: "Mobile · Adapter picker", args: { initialScreen: "adapter", initialName: "Darnold" }, globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
|
||||
export const RunnerAcpxClaude: Story = { name: "Runner · ACPX with Claude", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Claude (ACPX)" } };
|
||||
export const RunnerOpenCode: Story = { name: "Runner · OpenCode", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "OpenCode" } };
|
||||
|
||||
export const NativeCodexRunner: Story = { name: "Runner · Native Codex app server", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Codex (app server)" } };
|
||||
export const ClaudeAdapterApiKey: Story = { name: "Connect · Claude adapter · API key", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "claude_local", initialConnectionMethod: "api" } };
|
||||
export const CodexAdapterApiKey: Story = { name: "Connect · Codex adapter · API key", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "codex_local", initialConnectionMethod: "api" } };
|
||||
export const ClaudeRunnerApiKey: Story = { name: "Connect · Claude runner · API key", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Claude (ACPX)", initialConnectionMethod: "api" } };
|
||||
export const CodexRunnerApiKey: Story = { name: "Connect · Native Codex runner · API key", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Codex (app server)", initialConnectionMethod: "api" } };
|
||||
export const ClaudeAdapterSubscription: Story = { name: "Connect · Claude adapter · Subscription", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "claude_local", initialConnectionWaiting: true } };
|
||||
export const CodexAdapterSubscription: Story = { name: "Connect · Codex adapter · Subscription", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "codex_local", initialConnectionWaiting: true } };
|
||||
export const ClaudeRunnerSubscription: Story = { name: "Connect · Claude runner · Subscription", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Claude (ACPX)", initialConnectionWaiting: true } };
|
||||
export const CodexRunnerSubscription: Story = { name: "Connect · Native Codex runner · Subscription", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Codex (app server)", initialConnectionWaiting: true } };
|
||||
|
||||
export const ConnectYourAgent: Story = { name: "Connect · Choose subscription or API key", args: { initialScreen: "connect", initialName: "Darnold", initialAdapter: "claude_local" } };
|
||||
export const CodexConfirmation: Story = { name: "Confirmation · Native Codex runner", args: { initialScreen: "saved", initialName: "Darnold", initialAdapter: "paperclip_runner", initialRunnerProvider: "Codex (app server)" } };
|
||||
|
||||
export const RuntimeTestSucceeded: Story = { name: "Test · Succeeded", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "pi_local", initialTestState: "pass" } };
|
||||
export const RuntimeTestFailed: Story = { name: "Test · Failed", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "pi_local", initialTestState: "fail", testOutcome: "fail" } };
|
||||
export const RuntimeTestRunning: Story = { name: "Test · Running", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "codex_local", initialTestState: "running", testDelayMs: 10000 } };
|
||||
export const RuntimeTestRetry: Story = { name: "Test · Retry after failure", args: { initialScreen: "runtime", initialName: "Darnold", initialAdapter: "claude_local", initialTestState: "fail", testOutcome: "pass" } };
|
||||
|
||||
export const AssignFirstTask: Story = {
|
||||
name: "Confirmation · Assign a task",
|
||||
args: { initialScreen: "saved", initialName: "Nova", initialAdapter: "codex_local" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(within(canvasElement).getByRole("button", { name: "Assign Nova a Task" }));
|
||||
},
|
||||
};
|
||||
|
|
@ -337,3 +337,51 @@ export const PillMorph: StoryObj = {
|
|||
);
|
||||
},
|
||||
};
|
||||
|
||||
// These mount the shipped onboarding flow, not ConnectModelPreview. Selecting
|
||||
// a source opens its actual login panel against the Storybook API fixtures.
|
||||
function signedOutConnectionFixture() {
|
||||
clearOnboardingDraft();
|
||||
setOnboardingFixtureState({ environments: "managed-sandbox", authSignal: "absent" });
|
||||
return () => { clearOnboardingDraft(); resetOnboardingFixtureState(); };
|
||||
}
|
||||
|
||||
async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api") {
|
||||
await advance("Connect a model");
|
||||
if (mode === "api") {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Use API key instead" }, { timeout: STEP_TIMEOUT_MS }));
|
||||
}
|
||||
await userEvent.click(await screen.findByRole("radio", { name: new RegExp(`^${provider} `) }, { timeout: STEP_TIMEOUT_MS }));
|
||||
if (mode === "api") {
|
||||
await screen.findByLabelText("API key", {}, { timeout: STEP_TIMEOUT_MS });
|
||||
} else if (provider === "Claude") {
|
||||
await screen.findByLabelText("Authorization code", {}, { timeout: STEP_TIMEOUT_MS });
|
||||
} else {
|
||||
await screen.findByText("STORY-BOOK", {}, { timeout: STEP_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
export const ClaudeSubscription: StoryObj = {
|
||||
name: "Connect · Claude subscription",
|
||||
beforeEach: signedOutConnectionFixture,
|
||||
render: () => <WizardArc />,
|
||||
play: () => openProviderConnection("Claude", "subscription"),
|
||||
};
|
||||
export const CodexSubscription: StoryObj = {
|
||||
name: "Connect · Codex / OpenAI subscription",
|
||||
beforeEach: signedOutConnectionFixture,
|
||||
render: () => <WizardArc />,
|
||||
play: () => openProviderConnection("OpenAI", "subscription"),
|
||||
};
|
||||
export const ClaudeApiKey: StoryObj = {
|
||||
name: "Connect · Claude API key",
|
||||
beforeEach: signedOutConnectionFixture,
|
||||
render: () => <WizardArc />,
|
||||
play: () => openProviderConnection("Claude", "api"),
|
||||
};
|
||||
export const CodexApiKey: StoryObj = {
|
||||
name: "Connect · Codex / OpenAI API key",
|
||||
beforeEach: signedOutConnectionFixture,
|
||||
render: () => <WizardArc />,
|
||||
play: () => openProviderConnection("OpenAI", "api"),
|
||||
};
|
||||
|
|
|
|||