mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
feat(agents): bulk edit connections and default models (#17215)
Add harness-scoped bulk connection and default-model edits to the agent roster, with compatible choices, change previews, per-agent save results, and retries. Use an icon-based harness filter, keep agent rows readable, remove the status selector and selection hint, and redirect legacy status URLs to the full roster. Preserve authorization, audit history, unrelated settings, and optimistic concurrency checks through the existing agent update route. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
46760cc588
commit
9bf7dc0c8f
28 files changed
+1245
-223
No files matched your search
@@ -932,14 +932,14 @@ labelled primitive compositions with local state. Page navigation and
|
||||
collection filters remain in Tabs. This comparison does not migrate app styles
|
||||
or certify backend behavior or keyboard accessibility.
|
||||
|
||||
Agents and Connectors compose the same `ToolbarSearch` with canonical Standard filled tabs. `AgentsToolbar` is shared by the roster page and
|
||||
Agents and Connectors compose the same `ToolbarSearch`. Connectors also use canonical Standard filled tabs. `AgentsToolbar` is shared by the roster page and
|
||||
toolbar references; `ConnectorsToolbar` is shared by Browse and toolbar references. Keep search
|
||||
first, status/source selection next, and view/actions last where present.
|
||||
first, source selection next where present, and view/actions last. The agent roster uses a harness filter icon without a status selector.
|
||||
Connector source choices use radio semantics and preserve Browse's existing
|
||||
automatic search expansion; they do not switch separate content panels.
|
||||
Agents status navigation retains route-backed Tabs with its associated roster
|
||||
panel. Agent search intersects the chosen status/built-in filter, matches name,
|
||||
role and title words, and retains ancestor context for matching org-chart nodes.
|
||||
Legacy agent status and built-in URLs redirect to the full roster at `/agents/all`.
|
||||
Agent search matches name, role and title words, and retains ancestor context
|
||||
for matching org-chart nodes.
|
||||
|
||||
Projects uses `ProjectsToolbar` for shared search, the existing name/updated/
|
||||
created/target-date sort choices and direction toggling, and Add Project. Project
|
||||
|
||||
@@ -106,6 +106,7 @@ API: `present` — representative operation IDs:
|
||||
- `GET /api/agents/{id}/instructions-bundle/file`.
|
||||
- `PUT /api/agents/{id}/instructions-bundle/file`.
|
||||
- `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade.
|
||||
- `PATCH /api/agents/{id}` with `runtimeSettings: { harness, model?, aiConnection?, expected: { model, aiConnection } }` for a narrow roster edit. Each request handles one agent; the UI batches at most four writes and collects per-agent outcomes.
|
||||
|
||||
Agent access: `available` — Native read/update/history/restore instruction tools; REST /
|
||||
CLI.
|
||||
@@ -113,6 +114,13 @@ CLI.
|
||||
Policy: Agent creation/configuration uses action grants, resource policies and change
|
||||
consent; some controls require a board actor.
|
||||
|
||||
The roster intent preserves unrelated settings, rejects stale model/connection
|
||||
previews and harness changes, and checks the full execution configuration under
|
||||
the lifecycle write lock. It uses the same configuration, company, credential,
|
||||
environment, and consent checks as other edits. It adds no agent permission or
|
||||
credential fallback. Pool and externally managed agents use their existing
|
||||
configuration surfaces. Board browser verification does not qualify agent access.
|
||||
|
||||
The existing-agent upgrade notice uses this configuration PATCH. It does not
|
||||
grant configuration, account, or environment access. The server resolves the
|
||||
saved settings under normal company/configuration authorization and rejects
|
||||
|
||||
@@ -41,6 +41,21 @@ existing preparation and verification lifecycle.
|
||||
|
||||
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
|
||||
|
||||
In `/agents/all`, select roster checkboxes and choose **Edit connection & model**.
|
||||
A mixed selection offers one action per harness. Each field is opt-in. The editor
|
||||
previews each changed value and excludes agents already at the target value.
|
||||
Models must be compatible with every selected connection. Failed writes show
|
||||
per-agent errors; Retry touches only the remaining agents. Connection pools and
|
||||
externally managed agents are not editable through this shortcut.
|
||||
|
||||
Verify connection-only, model-only, both fields, mixed-harness selection, hidden
|
||||
selections, no-op counts, incompatible models, partial failure and retry. Reload
|
||||
the roster and compare stored settings, including unrelated heartbeat and adapter
|
||||
fields. A concurrent configuration edit must return a conflict. Shared runtime
|
||||
validation, access checks, revisions and audit logging still apply. Live runs
|
||||
retain their existing settings; model changes can reset saved sessions. Connection
|
||||
adoption can require an explicit reset of existing sessions.
|
||||
|
||||
### `organization`
|
||||
|
||||
Use the roster’s organization view; `/org` redirects to the roster in the streamlined shell.
|
||||
|
||||
@@ -48,3 +48,11 @@ export function agentHarnessType(adapterType: string, config: Record<string, unk
|
||||
export function agentRunner(adapterType: string): AgentRunner {
|
||||
return adapterType === "paperclip_runner" ? "paperclip" : "legacy";
|
||||
}
|
||||
|
||||
/** Stable configuration group across native and legacy execution backends. */
|
||||
export function agentRuntimeSettingsHarness(adapterType: string, config: Record<string, unknown> = {}): string {
|
||||
const harness = agentHarnessType(adapterType, config);
|
||||
if (harness === "acpx:copilot") return "copilot_runtime";
|
||||
if (harness === "cursor") return "cursor_local";
|
||||
return harness;
|
||||
}
|
||||
@@ -1862,6 +1862,8 @@ export {
|
||||
builtInAgentResetSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
agentRuntimeSettingsPatchSchema,
|
||||
type AgentRuntimeSettingsPatch,
|
||||
agentInstructionsBundleModeSchema,
|
||||
updateAgentInstructionsBundleSchema,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
@@ -2814,7 +2816,7 @@ export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPre
|
||||
export * from "./agent-avatar-upload.js";
|
||||
|
||||
export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js";
|
||||
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
|
||||
export { agentHarnessType, agentRuntimeSettingsHarness, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
|
||||
export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE, GROK_BOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
|
||||
|
||||
@@ -151,12 +151,28 @@ export const createAgentHireSchema = createAgentSchema.omit({ setupId: true }).e
|
||||
|
||||
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
|
||||
|
||||
// Narrow intent used by roster bulk edits. The server merges only these fields
|
||||
// into the latest configuration and rejects stale previews.
|
||||
export const agentRuntimeSettingsPatchSchema = z.object({
|
||||
harness: z.string().min(1),
|
||||
model: z.string().trim().min(1).max(512).optional(),
|
||||
aiConnection: aiConnectionBindingSchema.optional(),
|
||||
expected: z.object({
|
||||
model: z.string().nullable(),
|
||||
aiConnection: aiConnectionBindingSchema.nullable(),
|
||||
}).strict(),
|
||||
}).strict().refine(value => value.model !== undefined || value.aiConnection !== undefined, {
|
||||
message: "Choose a connection or default model to change",
|
||||
});
|
||||
export type AgentRuntimeSettingsPatch = z.infer<typeof agentRuntimeSettingsPatchSchema>;
|
||||
|
||||
export const updateAgentSchema = objectWithoutDefaults(
|
||||
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true, setupId: true }),
|
||||
)
|
||||
.partial()
|
||||
.extend({
|
||||
permissions: z.never().optional(),
|
||||
runtimeSettings: agentRuntimeSettingsPatchSchema.optional(),
|
||||
replaceAdapterConfig: z.boolean().optional(),
|
||||
status: z.enum(["paused", "idle", "terminated"]).optional(),
|
||||
spentMonthlyCents: z.never().optional(),
|
||||
|
||||
@@ -350,6 +350,8 @@ export {
|
||||
builtInAgentResetSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
agentRuntimeSettingsPatchSchema,
|
||||
type AgentRuntimeSettingsPatch,
|
||||
agentRuntimeConfigSchema,
|
||||
agentInstructionsBundleModeSchema,
|
||||
updateAgentInstructionsBundleSchema,
|
||||
|
||||
@@ -334,6 +334,30 @@ describe("agent routes adapter validation", () => {
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
});
|
||||
|
||||
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
|
||||
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
|
||||
mockAgentService.getById.mockResolvedValue(existing);
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ runtimeSettings: {
|
||||
harness: "codex_local", model: "new", expected: { model: "old", aiConnection: null },
|
||||
} }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.adapterConfig).toMatchObject({ model: "new", cwd: "/workspace" });
|
||||
expect(response.body.runtimeConfig).toEqual(existing.runtimeConfig);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.not.objectContaining({ runtimeSettings: expect.anything() }), expect.objectContaining({ expectedConfiguration: expect.objectContaining({ adapterConfig: existing.adapterConfig }), recordRevision: expect.anything() }));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated", entityId: existing.id }));
|
||||
});
|
||||
|
||||
it("refuses a roster edit without configuration permission", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runtimeSettings: {
|
||||
harness: "codex_local", model: "new", expected: { model: null, aiConnection: null },
|
||||
} }));
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentRuntimeSettingsPatchSchema, type AgentRuntimeSettingsPatch } from "@paperclipai/shared";
|
||||
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
||||
|
||||
const connection = { provider: "openai", method: "api_key", mode: "responsible_user" } as const;
|
||||
const existing = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "old", cwd: "/workspace", env: { PRESERVE: "value" } }, runtimeConfig: { heartbeat: { intervalSec: 900 }, aiConnection: connection }, status: "paused" };
|
||||
const settings: AgentRuntimeSettingsPatch = { harness: "codex_local", model: "new", expected: { model: "old", aiConnection: connection } };
|
||||
|
||||
describe("roster runtime settings patches", () => {
|
||||
it("changes only the requested fields and retains unrelated runtime settings", () => {
|
||||
expect(prepareRuntimeSettingsPatch(existing, { runtimeSettings: settings })).toEqual({ adapterConfig: { model: "new" } });
|
||||
const next = { ...connection, method: "subscription" } as const;
|
||||
expect(prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, model: undefined, aiConnection: next } })).toEqual({ runtimeConfig: { heartbeat: { intervalSec: 900 }, aiConnection: next } });
|
||||
expect(existing.adapterConfig.model).toBe("old");
|
||||
});
|
||||
it("rejects stale previews, harness switches, and mixed broad patches", () => {
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, harness: "claude_local" } })).toThrow("harness changed");
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, expected: { ...settings.expected, model: "stale" } } })).toThrow("connection or model changed");
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: settings, name: "Unexpected rename" })).toThrow("cannot be combined");
|
||||
expect(() => prepareRuntimeSettingsPatch({ ...existing, status: "terminated" }, { runtimeSettings: settings })).toThrow("cannot be updated");
|
||||
});
|
||||
it("requires an explicit edit and rejects extra fields inside the intent", () => {
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ harness: "codex_local", expected: settings.expected }).success).toBe(false);
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ ...settings, model: " " }).success).toBe(false);
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ ...settings, status: "idle" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ export interface ClaudeLoginContext {
|
||||
}
|
||||
|
||||
export interface UpdateAgentOptions {
|
||||
expectedConfiguration?: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "defaultEnvironmentId">;
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
|
||||
@@ -48,6 +48,15 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
async function hire(status = "idle") { return createAgentLifecycle(db).requestHire(companyId, { name: "Agent", status, adapterType: "process" }); }
|
||||
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
|
||||
|
||||
it("rejects an outdated configuration snapshot under the write lock", async () => {
|
||||
const agent = await hire();
|
||||
const snapshot = await current(agent.id);
|
||||
const expectedConfiguration = { adapterType: snapshot.adapterType, adapterConfig: snapshot.adapterConfig, runtimeConfig: snapshot.runtimeConfig, defaultEnvironmentId: snapshot.defaultEnvironmentId };
|
||||
await createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { intervalSec: 42 } } });
|
||||
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { aiConnection: {} } }, { expectedConfiguration })).rejects.toThrow("configuration changed while saving");
|
||||
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
|
||||
});
|
||||
|
||||
it("maps existing states without starting setup or clearing pause reasons", async () => {
|
||||
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
|
||||
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { conflict } from "../../../errors.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
@@ -11,6 +13,10 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
|
||||
assertConfigurationFields(data);
|
||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
|
||||
if (!current) return null;
|
||||
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
|
||||
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
|
||||
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
|
||||
}
|
||||
const patch = await effects.prepareConfiguration(tx, current, data, options);
|
||||
assertConfigurationFields(patch);
|
||||
const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
||||
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
|
||||
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
|
||||
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
@@ -22,6 +23,8 @@ import { withManagedAiProbe, stripAiAuthBindings } from "../services/ai-connecti
|
||||
import {
|
||||
ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
aiConnectionBindingSchema,
|
||||
aiConnectionMetadataSchema,
|
||||
aiRoutingModel,
|
||||
aiRuntimeConnectionBindingSchema,
|
||||
type AiConnectionBinding,
|
||||
type AiRuntimeConnectionBinding,
|
||||
@@ -3103,7 +3106,7 @@ export function agentRoutes(
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false, validateModelCatalog = false) {
|
||||
if (binding.mode === "router") {
|
||||
const settings = await import("../services/instance-settings.js");
|
||||
if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Ask your instance operator to enable AI connection routing.");
|
||||
@@ -3143,6 +3146,11 @@ export function agentRoutes(
|
||||
throw error;
|
||||
});
|
||||
if (!selection) return undefined;
|
||||
const routing = validateModelCatalog ? aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing : undefined;
|
||||
if (validateModelCatalog && routing?.models.length && !routing.models.some(model =>
|
||||
aiRoutingModel(routing, agentHarnessType(adapterType, config), model.id) === config.model)) {
|
||||
throw unprocessable("Choose a default model available on the selected connection");
|
||||
}
|
||||
// Copilot model availability is account-specific. Verify the selected
|
||||
// packaged runtime before creation/adoption even when a token is saved.
|
||||
// This metadata-only path sends no model prompt.
|
||||
@@ -5351,7 +5359,11 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const runtimeSettings = req.body.runtimeSettings;
|
||||
if (runtimeSettings) await assertCanConfigureAgentSetup(req, existing);
|
||||
const patchData = runtimeSettings
|
||||
? prepareRuntimeSettingsPatch(existing, req.body)
|
||||
: { ...(req.body as Record<string, unknown>) };
|
||||
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
||||
delete patchData.runner;
|
||||
const requestedStatus = patchData.status;
|
||||
@@ -5550,11 +5562,11 @@ export function agentRoutes(
|
||||
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
|
||||
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
|
||||
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
|
||||
if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
|
||||
const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup";
|
||||
await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding,
|
||||
(patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,
|
||||
!configuringSetup, configuringSetup);
|
||||
!configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings));
|
||||
}
|
||||
}
|
||||
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
|
||||
@@ -5582,6 +5594,10 @@ export function agentRoutes(
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const updateOptions = {
|
||||
...(runtimeSettings ? { expectedConfiguration: {
|
||||
adapterType: existing.adapterType, adapterConfig: existing.adapterConfig,
|
||||
runtimeConfig: existing.runtimeConfig, defaultEnvironmentId: existing.defaultEnvironmentId,
|
||||
} } : {}),
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { agentRuntimeSettingsHarness, type UpdateAgent } from "@paperclipai/shared";
|
||||
import type { AgentConfigurationRecord } from "../lib/agent-records.js";
|
||||
import { conflict, unprocessable } from "../errors.js";
|
||||
|
||||
/** Translate a scoped edit into the existing validated configuration write. */
|
||||
export function prepareRuntimeSettingsPatch(existing: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "status">, input: UpdateAgent): Record<string, unknown> {
|
||||
const settings = input.runtimeSettings!;
|
||||
if (Object.keys(input).some(key => key !== "runtimeSettings")) {
|
||||
throw unprocessable("Runtime settings cannot be combined with other agent changes");
|
||||
}
|
||||
const harness = agentRuntimeSettingsHarness(existing.adapterType, existing.adapterConfig);
|
||||
if (harness !== settings.harness) throw conflict("This agent's harness changed. Refresh the roster and select it again.");
|
||||
if (["process", "http"].includes(harness) || ["terminated", "pending_approval"].includes(existing.status)) {
|
||||
throw unprocessable("This agent cannot be updated from the roster");
|
||||
}
|
||||
const model = typeof existing.adapterConfig.model === "string" ? existing.adapterConfig.model.trim() || null : null;
|
||||
const binding = existing.runtimeConfig.aiConnection ?? null;
|
||||
if (model !== settings.expected.model || !isDeepStrictEqual(binding, settings.expected.aiConnection)) {
|
||||
throw conflict("This agent's connection or model changed. Refresh the roster and review the changes again.");
|
||||
}
|
||||
if (settings.aiConnection?.mode === "router" || (binding && typeof binding === "object" && "mode" in binding && binding.mode === "router")) {
|
||||
throw unprocessable("Edit connection pool members on the agent's configuration page");
|
||||
}
|
||||
return {
|
||||
...(settings.model !== undefined ? { adapterConfig: { model: settings.model } } : {}),
|
||||
...(settings.aiConnection !== undefined ? { runtimeConfig: { ...existing.runtimeConfig, aiConnection: settings.aiConnection } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -159,6 +159,8 @@ export const agentsApi = {
|
||||
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
||||
hire: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
||||
updateRuntimeSettings: (id: string, runtimeSettings: import("@paperclipai/shared").AgentRuntimeSettingsPatch, companyId: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId), { runtimeSettings }),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId), data),
|
||||
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { List, Network, Plus } from "lucide-react";
|
||||
import { ToolbarSearch } from "./ToolbarSearch";
|
||||
import { PageTabBar, type PageTabItem } from "./PageTabBar";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export interface AgentsToolbarProps {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
tab: string;
|
||||
items: readonly PageTabItem[];
|
||||
onTabChange: (value: string) => void;
|
||||
view: "list" | "org";
|
||||
onViewChange: (value: "list" | "org") => void;
|
||||
hideViewToggle?: boolean;
|
||||
onNewAgent: () => void;
|
||||
}
|
||||
|
||||
/** Shared roster search, status navigation and actions for the page and stories. */
|
||||
export function AgentsToolbar({ query, onQueryChange, tab, items, onTabChange, view, onViewChange, hideViewToggle, onNewAgent }: AgentsToolbarProps) {
|
||||
/** Shared roster search, view controls and actions for the page and stories. */
|
||||
export function AgentsToolbar({ query, onQueryChange, view, onViewChange, hideViewToggle, onNewAgent }: AgentsToolbarProps) {
|
||||
return <div data-canonical-toolbar="agents" role="toolbar" aria-label="Agents controls" className="flex flex-wrap items-center gap-3">
|
||||
<div className="design-toolbar-controls flex w-full flex-wrap items-center gap-4">
|
||||
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search agents" placeholder="Search agents…" />
|
||||
@@ -28,8 +24,5 @@ export function AgentsToolbar({ query, onQueryChange, tab, items, onTabChange, v
|
||||
<Button size="sm" variant="outline" data-toolbar-primary onClick={onNewAgent}><Plus aria-hidden />New Agent</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collection-toolbar-tabs">
|
||||
<PageTabBar items={items} value={tab} onValueChange={onTabChange} align="start" />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { lazy, Suspense, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Filter, Pencil, X } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
|
||||
import { agentConnection, agentDefaultModel, bulkAgentDisabledReason, bulkAgentHarness, bulkConnectionName } from "@/lib/bulk-agent-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AgentBulkSettingsSheet = lazy(() => import("./AgentBulkSettingsSheet").then(m => ({ default: m.AgentBulkSettingsSheet })));
|
||||
|
||||
/** Shared by both roster layouts. Mount with a company key to isolate selection. */
|
||||
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent }: {
|
||||
companyId: string; agents: Agent[]; visibleAgents: Agent[]; renderAgent: (agent: Agent) => ReactNode;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [editing, setEditing] = useState<Agent[] | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId), retry: false });
|
||||
const visible = visibleAgents.filter(a => filter === "all" || bulkAgentHarness(a) === filter);
|
||||
const selectable = visible.filter(a => !bulkAgentDisabledReason(a));
|
||||
const selectedAgents = agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a));
|
||||
const harnesses = [...new Set(agents.map(bulkAgentHarness))].sort((a, b) => getAdapterLabel(a).localeCompare(getAdapterLabel(b)));
|
||||
const groups = [...new Set(selectedAgents.map(bulkAgentHarness))];
|
||||
const all = selectable.length > 0 && selectable.every(a => selected.includes(a.id));
|
||||
const some = selectable.some(a => selected.includes(a.id));
|
||||
const hidden = selectedAgents.filter(a => !visible.includes(a)).length;
|
||||
const toggleAll = () => setSelected(all ? selected.filter(id => !selectable.some(a => a.id === id)) : [...new Set([...selected, ...selectable.map(a => a.id)])]);
|
||||
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Filter agents" title={filter === "all" ? "Filter" : `Filter: ${getAdapterLabel(filter)}`}
|
||||
className={cn("relative h-8 w-8 shrink-0", filter !== "all" && "text-blue-600 dark:text-blue-400")}>
|
||||
<Filter className="h-3.5 w-3.5" aria-hidden />
|
||||
{filter !== "all" && <span aria-hidden className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-(length:--text-nano) font-bold text-white">1</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent aria-label="Agent filters" align="start" className="w-64 space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
{filter !== "all" && <button type="button" className="text-xs text-muted-foreground hover:text-foreground" onClick={() => setFilter("all")}>Clear</button>}
|
||||
</div>
|
||||
<div role="group" aria-label="Harness" className="space-y-1">
|
||||
<div className="px-2 text-xs font-medium text-muted-foreground">Harness</div>
|
||||
{["all", ...harnesses].map(h => <button key={h} type="button" aria-pressed={filter === h} onClick={() => setFilter(h)}
|
||||
className={cn("flex w-full items-center justify-between rounded-sm px-2 py-1 text-left text-sm", filter === h ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground")}>
|
||||
{h === "all" ? "All harnesses" : getAdapterLabel(h)}{filter === h && <Check className="size-3.5" aria-hidden />}
|
||||
</button>)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-xs text-muted-foreground">{visible.length} {visible.length === 1 ? "agent" : "agents"}</span>
|
||||
</div>
|
||||
{selectedAgents.length > 0 && <div role="region" aria-label="Bulk actions" className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-md border bg-muted p-3">
|
||||
<span className="text-sm font-medium">{selectedAgents.length} selected</span>
|
||||
{hidden > 0 && <span className="text-xs text-muted-foreground">{hidden} hidden by filters</span>}
|
||||
{groups.length > 1 && <span className="text-xs text-muted-foreground">Choose a harness to edit</span>}
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(h => <Button key={h} size="sm" variant="outline" onClick={() => setEditing(selectedAgents.filter(a => bulkAgentHarness(a) === h))}>
|
||||
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${selectedAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
|
||||
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => setSelected([])}><X className="size-4" /></Button></div>
|
||||
</div>}
|
||||
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
|
||||
{accounts.error && <p className="text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={() => void accounts.refetch()}>Retry</Button></p>}
|
||||
<div className="overflow-x-auto"><table aria-label="Agent roster" className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr>
|
||||
<th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={all ? true : some ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th>
|
||||
<th>Agent</th><th>Harness</th><th>Connection</th><th>Default model</th>
|
||||
</tr></thead>
|
||||
<tbody>{visible.map(agent => <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 [&_td]:px-3">
|
||||
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => setSelected(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
|
||||
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
|
||||
<td className="whitespace-nowrap text-xs">{getAdapterLabel(bulkAgentHarness(agent))}</td>
|
||||
<td className="text-xs">{["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}</td>
|
||||
<td className="font-mono text-xs">{agentDefaultModel(agent) ?? "Harness default"}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
{!visible.length && <p className="py-8 text-center text-sm text-muted-foreground">No agents match these filters.</p>}
|
||||
{editing && <Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading editor…</p>}><AgentBulkSettingsSheet companyId={companyId} agents={editing} totalSelected={selectedAgents.length} onClose={(updatedIds, complete) => {
|
||||
setEditing(null);
|
||||
if (updatedIds.length) {
|
||||
setSelected(ids => ids.filter(id => !(complete ? editing.map(a => a.id) : updatedIds).includes(id)));
|
||||
setNotice(`Updated ${updatedIds.length} ${getAdapterLabel(bulkAgentHarness(editing[0]))} ${updatedIds.length === 1 ? "agent" : "agents"}.`);
|
||||
}
|
||||
}} /></Suspense>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { storybookAgents } from "../../../storybook/fixtures/paperclipData";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { AgentBulkSettingsSheet } from "./AgentBulkSettingsSheet";
|
||||
|
||||
const api = vi.hoisted(() => ({ models: vi.fn(), accounts: vi.fn(), update: vi.fn(), get: vi.fn() }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: { adapterModels: api.models, updateRuntimeSettings: api.update, get: api.get } }));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: api.accounts } }));
|
||||
|
||||
let root: Root;
|
||||
let client: QueryClient;
|
||||
const onClose = vi.fn();
|
||||
const agent = (id: string, adapterType: Agent["adapterType"] = "hermes_local"): Agent => ({
|
||||
...storybookAgents[0], id, name: id, companyId: "company-1", adapterType,
|
||||
adapterConfig: { model: "old-model" }, runtimeConfig: {},
|
||||
});
|
||||
|
||||
async function render(agents: Agent[]) {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider>
|
||||
<AgentBulkSettingsSheet companyId="company-1" agents={agents} totalSelected={agents.length} onClose={onClose} />
|
||||
</TooltipProvider></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
}
|
||||
|
||||
function button(name: string) {
|
||||
const result = [...document.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.find(el => el.getAttribute("aria-label") === name || el.textContent?.trim() === name);
|
||||
if (!result) throw new Error(`Missing button: ${name}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function chooseModel(id: string, manual = false) {
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Change default model"]')!.click());
|
||||
await act(async () => button("Model").click());
|
||||
if (manual) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[placeholder^="Search models"]')!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, id);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
const option = [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')]
|
||||
.find(el => manual ? el.textContent?.includes("Use manual model") : el.textContent?.trim() === id);
|
||||
expect(option).toBeDefined();
|
||||
await act(async () => option!.click());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.accounts.mockResolvedValue({ currentUserId: "board", canManageConnections: true, connections: [], pools: [] });
|
||||
api.models.mockResolvedValue([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
client?.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("bulk model editing", () => {
|
||||
it("saves a manual Hermes model when the local adapter has no catalog", async () => {
|
||||
const hermes = agent("Hermes");
|
||||
api.update.mockResolvedValue({ ...hermes, adapterConfig: { model: "provider/custom-model" } });
|
||||
await render([hermes]);
|
||||
await chooseModel("provider/custom-model", true);
|
||||
await vi.waitFor(() => expect(button("Update 1 agent").disabled).toBe(false));
|
||||
await act(async () => button("Update 1 agent").click());
|
||||
await vi.waitFor(() => expect(onClose).toHaveBeenCalledWith(["Hermes"], true));
|
||||
expect(api.update).toHaveBeenCalledWith("Hermes", expect.objectContaining({ model: "provider/custom-model", harness: "hermes_local" }), "company-1");
|
||||
});
|
||||
|
||||
it("does not offer manual entry for a restricted connection catalog", async () => {
|
||||
const codex = agent("Codex", "codex_local");
|
||||
codex.runtimeConfig = { aiConnection: { provider: "openai", method: "api_key", mode: "shared", connectionId: "10000000-0000-4000-8000-000000000001", grantId: "10000000-0000-4000-8000-000000000002" } };
|
||||
api.accounts.mockResolvedValue({ currentUserId: "board", connections: [{ id: "10000000-0000-4000-8000-000000000001", grantId: "10000000-0000-4000-8000-000000000002", routing: { kind: "gateway", protocol: "responses", models: [{ id: "allowed-model" }] } }] });
|
||||
await render([codex]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Change default model"]')!.click());
|
||||
await act(async () => button("Model").click());
|
||||
expect(document.querySelector('input[placeholder="Search models..."]')).not.toBeNull();
|
||||
expect(document.querySelector('input[placeholder*="type to create"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("retries only failed agents even when a saved agent's model probe becomes unavailable", async () => {
|
||||
const atlas = agent("Atlas", "paperclip_runner");
|
||||
const scout = agent("Scout", "paperclip_runner");
|
||||
atlas.adapterConfig = scout.adapterConfig = { provider: "acpx", acpxAgent: "copilot", model: "old-model" };
|
||||
let partialSave = false;
|
||||
api.models.mockImplementation(async (_company, _adapter, options) => {
|
||||
if (partialSave && options.agentId === "Atlas") throw new Error("Environment unavailable");
|
||||
return [{ id: "new-model", label: "new-model" }];
|
||||
});
|
||||
api.update.mockImplementation(async (id, patch) => {
|
||||
if (id === "Scout" && !partialSave) { partialSave = true; throw new Error("Try again"); }
|
||||
const original = id === "Atlas" ? atlas : scout;
|
||||
return { ...original, adapterConfig: { ...original.adapterConfig, model: patch.model } };
|
||||
});
|
||||
api.get.mockResolvedValue(scout);
|
||||
await render([atlas, scout]);
|
||||
await chooseModel("new-model");
|
||||
await act(async () => button("Update 2 agents").click());
|
||||
await vi.waitFor(() => expect(button("Retry 1 agent")).toBeDefined());
|
||||
api.models.mockClear();
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["bulk-agent-models"] }); });
|
||||
await vi.waitFor(() => expect(button("Retry 1 agent").disabled).toBe(false));
|
||||
expect(api.models.mock.calls.every(([, , options]) => options.agentId === "Scout")).toBe(true);
|
||||
expect(api.models).toHaveBeenCalled();
|
||||
await act(async () => button("Retry 1 agent").click());
|
||||
await vi.waitFor(() => expect(onClose).toHaveBeenCalledWith(["Atlas", "Scout"], true));
|
||||
expect(api.update.mock.calls.map(([id]) => id)).toEqual(["Atlas", "Scout", "Scout"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
import { aiRoutingModel, isAiConnectionCompatible, type Agent, type AiConnectionBinding, type AiConnectionList } from "@paperclipai/shared";
|
||||
import { agentsApi, type AdapterModel } from "@/api/agents";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
|
||||
import { agentConnection, agentDefaultModel, bulkAgentHarness, bulkConnectionName, mapBulkAgents, resolveBulkConnection, sameBinding } from "@/lib/bulk-agent-settings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { ModelDropdown } from "@/components/AgentConfigForm";
|
||||
import { AiConnectionField, aiProviderForAdapter } from "@/components/ai-connections/AiConnectionField";
|
||||
import { bindingProblem } from "@/components/ai-connections/model";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
type Catalog = { agent: Agent; accounts: AiConnectionList; binding: AiConnectionBinding | undefined; models: AdapterModel[]; restricted: boolean };
|
||||
type Failure = { agent: Agent; message: string };
|
||||
|
||||
export function AgentBulkSettingsSheet({ companyId, agents, totalSelected, onClose }: {
|
||||
companyId: string; agents: Agent[]; totalSelected: number; onClose: (updatedIds: string[], complete?: boolean) => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const harness = bulkAgentHarness(agents[0]);
|
||||
const label = getAdapterLabel(harness);
|
||||
const [changeConnection, setChangeConnection] = useState(false);
|
||||
const [changeModel, setChangeModel] = useState(false);
|
||||
const [binding, setBinding] = useState<AiConnectionBinding>();
|
||||
const [selectionError, setSelectionError] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingRef = useRef(false);
|
||||
const [updatedIds, setUpdatedIds] = useState<string[]>([]);
|
||||
const [failures, setFailures] = useState<Failure[] | null>(null);
|
||||
const targets = failures ? failures.map(f => f.agent) : agents;
|
||||
const catalogs = useQuery({
|
||||
queryKey: ["bulk-agent-models", companyId, targets.map(a => a.id), changeConnection ? binding : null],
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const modelRequests = new Map<string, Promise<AdapterModel[]>>();
|
||||
const results = await mapBulkAgents(targets, async agent => {
|
||||
const accounts = await aiConnectionsApi.list(companyId, agent.id);
|
||||
const current = agentConnection(agent);
|
||||
const effective = changeConnection && binding ? binding : current?.mode !== "router" ? current : undefined;
|
||||
const connection = resolveBulkConnection(effective, accounts);
|
||||
const routing = connection?.routing;
|
||||
const openRouter = routing?.kind === "openrouter" || (!routing && effective?.provider === "openrouter");
|
||||
const restricted = Boolean(routing?.models.length || openRouter || harness === "copilot_runtime");
|
||||
let models: AdapterModel[];
|
||||
if (routing?.models.length) {
|
||||
models = routing.models.map(m => ({ id: aiRoutingModel(routing, harness, m.id), label: m.label ?? m.id }));
|
||||
} else {
|
||||
const copilot = harness === "copilot_runtime";
|
||||
const type = openRouter ? "opencode_local" : copilot ? "paperclip_runner" : harness;
|
||||
const options = openRouter ? { provider: "openrouter" } : copilot
|
||||
? { provider: "acpx", acpxAgent: "copilot", agentId: agent.id, aiConnection: effective, environmentId: agent.defaultEnvironmentId }
|
||||
: { environmentId: agent.defaultEnvironmentId };
|
||||
const key = JSON.stringify([type, options]);
|
||||
let request = modelRequests.get(key);
|
||||
if (!request) { request = agentsApi.adapterModels(companyId, type, options); modelRequests.set(key, request); }
|
||||
models = await request;
|
||||
if (openRouter && !["opencode_local", "pi_local"].includes(harness)) models = models.map(m => ({ ...m, id: m.id.replace(/^openrouter\//, "") }));
|
||||
}
|
||||
return { agent, accounts, binding: effective, models, restricted } satisfies Catalog;
|
||||
});
|
||||
return results.map((result, index) => result.status === "fulfilled" ? result.value : { agent: targets[index], error: result.reason instanceof Error ? result.reason.message : "Couldn’t load connections or models" });
|
||||
},
|
||||
});
|
||||
const rows = catalogs.data?.filter((r): r is Catalog => !("error" in r)) ?? [];
|
||||
const loadErrors = catalogs.data?.filter(r => "error" in r) ?? [];
|
||||
const availableModels = [...new Map(rows.flatMap(r => r.models).map(m => [m.id, m])).values()]
|
||||
.filter(m => rows.every(r => !r.restricted || r.models.some(other => other.id === m.id)));
|
||||
const allowManualModel = rows.length === targets.length && rows.every(r => !r.restricted);
|
||||
const changed = targets.filter(a => (changeConnection && binding && !sameBinding(binding, agentConnection(a))) || (changeModel && model && model !== agentDefaultModel(a)));
|
||||
const missing = (!changeConnection && !changeModel) || (changeConnection && !binding) || (changeModel && !model);
|
||||
const problems = targets.flatMap(agent => {
|
||||
const row = rows.find(r => r.agent.id === agent.id);
|
||||
if (!row || missing) return [];
|
||||
const nextModel = changeModel ? model : agentDefaultModel(agent);
|
||||
let problem: string | null = null;
|
||||
if (row.binding) {
|
||||
problem = bindingProblem(row.binding, { companyId, provider: row.binding.provider }, row.accounts.connections, row.accounts.currentUserId, agent.id);
|
||||
if (!problem && !isAiConnectionCompatible(row.binding, harness, nextModel)) problem = "Choose a model compatible with this connection.";
|
||||
}
|
||||
if (!problem && row.restricted && !row.models.some(m => m.id === nextModel)) problem = `${nextModel ?? "Harness default"} isn’t available on this connection. Choose a different connection or change the default model.`;
|
||||
return problem ? [{ agent, message: problem }] : [];
|
||||
});
|
||||
const canSave = !saving && !missing && !selectionError && !catalogs.isFetching && !catalogs.error && !loadErrors.length && rows.length === targets.length && !problems.length && changed.length > 0;
|
||||
const updateCount = changed.length || agents.length;
|
||||
const otherSelected = totalSelected - agents.length;
|
||||
const currentConnections = [...new Set(agents.map(a => bulkConnectionName(agentConnection(a), rows.find(r => r.agent.id === a.id)?.accounts)))];
|
||||
const currentModels = [...new Set(agents.map(a => agentDefaultModel(a) ?? "Harness default"))];
|
||||
const close = () => { if (!savingRef.current) onClose(updatedIds); };
|
||||
|
||||
async function save() {
|
||||
if (!canSave || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
const results = await mapBulkAgents(changed, async agent => {
|
||||
const patch = { harness, ...(changeConnection && binding ? { aiConnection: binding } : {}), ...(changeModel ? { model } : {}), expected: { model: agentDefaultModel(agent), aiConnection: agentConnection(agent) ?? null } };
|
||||
// A lost response may hide a successful commit. Re-read before retrying so
|
||||
// an acknowledged or already-applied edit never creates a second write.
|
||||
if (failures) {
|
||||
const latest = await agentsApi.get(agent.id, companyId);
|
||||
if (bulkAgentHarness(latest) === harness && (!changeConnection || sameBinding(binding, agentConnection(latest))) && (!changeModel || agentDefaultModel(latest) === model)) return latest;
|
||||
}
|
||||
return agentsApi.updateRuntimeSettings(agent.id, patch, companyId);
|
||||
});
|
||||
const nextFailures: Failure[] = [];
|
||||
const successes: Agent[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") successes.push(result.value);
|
||||
else nextFailures.push({ agent: changed[index], message: result.reason instanceof Error ? result.reason.message : "Couldn’t save this agent. Try again." });
|
||||
});
|
||||
const allUpdated = [...updatedIds, ...successes.map(a => a.id)];
|
||||
setUpdatedIds(allUpdated);
|
||||
client.setQueryData<Agent[]>(queryKeys.agents.list(companyId), current => current?.map(a => successes.find(updated => updated.id === a.id) ?? a));
|
||||
await Promise.all(successes.map(a => client.invalidateQueries({ queryKey: queryKeys.agents.detail(a.id) })));
|
||||
void client.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
if (nextFailures.length) setFailures(nextFailures);
|
||||
else onClose(allUpdated, true);
|
||||
}
|
||||
|
||||
return <Sheet open onOpenChange={open => { if (!open) close(); }}><SheetContent className="w-full sm:max-w-lg" showCloseButton={!saving} onEscapeKeyDown={event => { if (savingRef.current) event.preventDefault(); }} onInteractOutside={event => { if (savingRef.current) event.preventDefault(); }}>
|
||||
<SheetHeader className="px-6 pt-6 pb-0"><SheetTitle>Update {agents.length} {label} {agents.length === 1 ? "agent" : "agents"}</SheetTitle><SheetDescription>{totalSelected > agents.length ? `${otherSelected} other selected ${otherSelected === 1 ? "agent" : "agents"} won’t change.` : "Change their connection, default model, or both."}</SheetDescription></SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-2">
|
||||
<div className="flex flex-wrap gap-2" aria-label="Agents to update">{agents.map(a => <span key={a.id} className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs"><AgentAvatar agent={a} size={20} />{a.name}</span>)}</div>
|
||||
{failures && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm"><p className="font-medium">{updatedIds.length} updated · {failures.length} couldn’t be saved</p>{failures.map(f => <p key={f.agent.id} className="text-muted-foreground">{f.agent.name}: {f.message}</p>)}<p className="text-xs text-muted-foreground">Retry updates only the remaining agents. Close and reopen to review changed settings.</p></div>}
|
||||
<fieldset disabled={saving || failures !== null} className="flex flex-col gap-6 disabled:opacity-60">
|
||||
<section className="space-y-3"><label className="flex cursor-pointer items-start gap-3"><Checkbox checked={changeConnection} disabled={!aiProviderForAdapter(harness)} onCheckedChange={value => setChangeConnection(value === true)} aria-label="Change connection" className="mt-0.5" /><span className="space-y-1"><span className="block text-sm font-medium">Change connection</span><span className="block text-xs text-muted-foreground">{currentConnections.length === 1 ? currentConnections[0] : `${currentConnections.length} different connections`}{!changeConnection && " · Keep current"}</span></span></label>
|
||||
{changeConnection && <div className="pl-7"><AiConnectionField companyId={companyId} agentId={agents[0].id} agentName={`${agents.length} agents`} adapterType={harness} model={changeModel ? model : undefined} value={binding} readOnly={saving || failures !== null} onChange={value => {
|
||||
if (value.mode === "router") { setSelectionError("Configure connection pools on each agent’s configuration page."); return; }
|
||||
setSelectionError(""); setBinding(value);
|
||||
}} /></div>}
|
||||
</section>
|
||||
<section className="space-y-3"><label className="flex cursor-pointer items-start gap-3"><Checkbox checked={changeModel} onCheckedChange={value => setChangeModel(value === true)} aria-label="Change default model" className="mt-0.5" /><span className="space-y-1"><span className="block text-sm font-medium">Change default model</span><span className="block text-xs text-muted-foreground">{currentModels.length === 1 ? currentModels[0] : `${currentModels.length} different models`}{!changeModel && " · Keep current"}</span></span></label>
|
||||
{changeModel && <div className="pl-7"><ModelDropdown models={availableModels} value={model} onChange={setModel} open={modelOpen} onOpenChange={setModelOpen} allowDefault={false} required groupByProvider={false} preserveOrder creatable={allowManualModel} /><p className="pt-2 text-xs text-muted-foreground">{allowManualModel ? "Choose a suggested model or enter a model ID supported by your harness." : `Available on every selected agent’s ${changeConnection ? "new connection" : "current connection"}.`}</p></div>}
|
||||
</section>
|
||||
</fieldset>
|
||||
{catalogs.isFetching && <p role="status" className="text-xs text-muted-foreground">Checking connections and models…</p>}
|
||||
{(catalogs.error || loadErrors.length > 0) && <div role="alert" className="space-y-2 text-sm text-destructive"><p>{catalogs.error?.message}</p>{loadErrors.map(r => <p key={r.agent.id}>{r.agent.name}: {"error" in r ? r.error : ""}</p>)}<Button variant="outline" size="sm" onClick={() => void catalogs.refetch()}>Retry loading options</Button></div>}
|
||||
{selectionError && <p role="alert" className="text-sm text-destructive">{selectionError}</p>}
|
||||
{problems.length > 0 && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm">{problems.map(p => <p key={p.agent.id}><span className="font-medium">{p.agent.name}: </span>{p.message}</p>)}{!changeModel && !failures && <Button size="sm" variant="outline" onClick={() => setChangeModel(true)}>Change default model</Button>}</div>}
|
||||
{!missing && changed.length > 0 && <section className="space-y-3" aria-label="Changes to apply"><h3 className="text-xs font-medium text-muted-foreground">CHANGES TO APPLY</h3><div className="divide-y divide-border">{changed.map(agent => <div key={agent.id} className="space-y-1.5 py-3 text-xs"><p className="text-sm font-medium">{agent.name}</p>{changeConnection && !sameBinding(binding, agentConnection(agent)) && <p className="flex flex-wrap items-center gap-1.5 text-muted-foreground"><span>{bulkConnectionName(agentConnection(agent), rows.find(r => r.agent.id === agent.id)?.accounts)}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{bulkConnectionName(binding, rows.find(r => r.agent.id === agent.id)?.accounts)}</span></p>}{changeModel && model !== agentDefaultModel(agent) && <p className="flex flex-wrap items-center gap-1.5 font-mono text-muted-foreground"><span>{agentDefaultModel(agent) ?? "Harness default"}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{model}</span></p>}</div>)}</div>{targets.length > changed.length && <p className="text-xs text-muted-foreground">{targets.length - changed.length} already have these settings.</p>}</section>}
|
||||
{!missing && !changed.length && <p className="text-sm text-muted-foreground">All selected agents already have these settings.</p>}
|
||||
<p className="text-xs text-muted-foreground">Applies to future runs. Running work keeps its current settings. Task-specific model overrides stay in place. A model change may start a fresh session; existing sessions may retain their connection until reset.</p>
|
||||
</div>
|
||||
<SheetFooter className="flex-row items-center justify-between border-t px-6 py-4"><Button variant="ghost" onClick={close} disabled={saving}>{failures ? "Close" : "Cancel"}</Button><Button onClick={() => void save()} disabled={!canSave}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Updating…" : failures ? `Retry ${failures.length} ${failures.length === 1 ? "agent" : "agents"}` : `Update ${updateCount} ${updateCount === 1 ? "agent" : "agents"}`}</Button></SheetFooter>
|
||||
</SheetContent></Sheet>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapBulkAgents, sameBinding } from "./bulk-agent-settings";
|
||||
|
||||
describe("bulk agent writes", () => {
|
||||
it("bounds concurrent requests and retains each success and failure in input order", async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const results = await mapBulkAgents([0, 1, 2, 3, 4, 5, 6], async id => {
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise(resolve => setTimeout(resolve, id === 0 ? 15 : 1));
|
||||
active--;
|
||||
if (id === 2) throw new Error("Unavailable");
|
||||
return id;
|
||||
});
|
||||
expect(peak).toBe(4);
|
||||
expect(results.map(r => r.status === "fulfilled" ? r.value : "failed")).toEqual([0, 1, "failed", 3, 4, 5, 6]);
|
||||
});
|
||||
it("compares binding identity independently of JSON field order", () => {
|
||||
expect(sameBinding({ provider: "openai", method: "api_key", mode: "responsible_user" }, { mode: "responsible_user", method: "api_key", provider: "openai" })).toBe(true);
|
||||
expect(sameBinding(undefined, { mode: "responsible_user", method: "api_key", provider: "openai" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { agentRuntimeSettingsHarness, aiRuntimeConnectionBindingSchema, type Agent, type AiConnectionBinding, type AiConnectionList, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
|
||||
export const bulkAgentHarness = (agent: Agent) => agentRuntimeSettingsHarness(agent.adapterType, agent.adapterConfig);
|
||||
export const agentDefaultModel = (agent: Agent) => typeof agent.adapterConfig.model === "string" ? agent.adapterConfig.model.trim() || null : null;
|
||||
export const agentConnection = (agent: Agent) => aiRuntimeConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||||
export const sameBinding = (a: AiRuntimeConnectionBinding | undefined, b: AiRuntimeConnectionBinding | undefined) => {
|
||||
const canonical = (value: AiRuntimeConnectionBinding | undefined) => JSON.stringify(value && Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))));
|
||||
return canonical(a) === canonical(b);
|
||||
};
|
||||
|
||||
export function bulkAgentDisabledReason(agent: Agent): string | undefined {
|
||||
if (["terminated", "pending_approval"].includes(agent.status)) return "This agent is not available for bulk edits";
|
||||
if (agentConnection(agent)?.mode === "router") return "Edit connection pool members on the agent's configuration page";
|
||||
if (!["codex_local", "claude_local", "gemini_local", "grok_local", "opencode_local", "hermes_local", "pi_local", "copilot_runtime", "cursor_local"].includes(bulkAgentHarness(agent))) return "Connection and model are managed externally";
|
||||
}
|
||||
|
||||
export function resolveBulkConnection(binding: AiConnectionBinding | undefined, accounts: AiConnectionList) {
|
||||
return accounts.connections.find(c => binding?.mode === "responsible_user"
|
||||
? c.provider === binding.provider && c.isDefault && c.ownership === "personal" && c.ownerUserId === accounts.currentUserId
|
||||
: c.id === binding?.connectionId && c.grantId === binding?.grantId);
|
||||
}
|
||||
|
||||
export function bulkConnectionName(binding: AiRuntimeConnectionBinding | undefined, accounts?: AiConnectionList) {
|
||||
if (!binding) return "Local credentials";
|
||||
if (binding.mode === "router") return "Connection pool";
|
||||
if (binding.mode === "responsible_user") return "Responsible user's default";
|
||||
return accounts?.connections.find(c => c.id === binding.connectionId && c.grantId === binding.grantId)?.name ?? "Assigned connection";
|
||||
}
|
||||
|
||||
/** Bound probes and mutations; failures belong to individual agents. */
|
||||
export async function mapBulkAgents<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency = 4): Promise<PromiseSettledResult<R>[]> {
|
||||
const results: PromiseSettledResult<R>[] = new Array(items.length);
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
try { results[index] = { status: "fulfilled", value: await fn(items[index]) }; }
|
||||
catch (reason) { results[index] = { status: "rejected", reason }; }
|
||||
}
|
||||
}));
|
||||
return results;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
|
||||
import { AgentBulkRoster } from "@/components/agents/AgentBulkRoster";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate, useLocation } from "@/lib/router";
|
||||
@@ -20,15 +21,9 @@ import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
useResourceMemberships,
|
||||
} from "../hooks/useResourceMemberships";
|
||||
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
||||
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
@@ -46,14 +41,6 @@ const ConfigureBuiltInAgentModal = lazy(() =>
|
||||
export const AGENT_FILTER_TABS = ["all", "active", "paused", "error", "builtin"] as const;
|
||||
type FilterTab = (typeof AGENT_FILTER_TABS)[number];
|
||||
|
||||
const AGENT_FILTER_TAB_ITEMS: { value: FilterTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "builtin", label: "Built-in" },
|
||||
];
|
||||
|
||||
function isFilterTab(value: string): value is FilterTab {
|
||||
return (AGENT_FILTER_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -204,12 +191,7 @@ export function Agents() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const builtInAgentsEnabled = instanceSettings?.experimental.enableBuiltInAgents === true;
|
||||
const tab: FilterTab = requestedTab === "builtin" && !builtInAgentsEnabled ? "all" : requestedTab;
|
||||
const visibleTabItems = useMemo(
|
||||
() => AGENT_FILTER_TAB_ITEMS.filter((item) => item.value !== "builtin" || builtInAgentsEnabled),
|
||||
[builtInAgentsEnabled],
|
||||
);
|
||||
|
||||
const tab: FilterTab = "all";
|
||||
const { data: builtInAgents } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
|
||||
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
|
||||
@@ -268,7 +250,6 @@ export function Agents() {
|
||||
refetchInterval: sharedRuns.refetchInterval,
|
||||
});
|
||||
usePublishSharedQueryData(sharedRuns, runs, runsUpdatedAt);
|
||||
const membershipsQuery = useResourceMemberships(selectedCompanyId);
|
||||
|
||||
// Map agentId -> first live run + live run count
|
||||
const liveRunByAgent = useMemo(() => {
|
||||
@@ -318,10 +299,10 @@ export function Agents() {
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCompanyId && requestedTab === "builtin" && instanceSettings && !builtInAgentsEnabled) {
|
||||
if (requestedTab !== "all") {
|
||||
navigate("/agents/all", { replace: true });
|
||||
}
|
||||
}, [builtInAgentsEnabled, instanceSettings, navigate, requestedTab, selectedCompanyId]);
|
||||
}, [navigate, requestedTab]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
@@ -380,45 +361,28 @@ export function Agents() {
|
||||
// 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"
|
||||
titleClassName="flex-1"
|
||||
titleTextClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
|
||||
subtitleClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
|
||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
className={cn(
|
||||
"group",
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "sm:text-foreground/55" : "",
|
||||
)}
|
||||
className="group"
|
||||
leading={hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : (
|
||||
<AgentAvatar agent={agent} size={32} />
|
||||
)}
|
||||
secondaryRow={
|
||||
builtInCluster ? (
|
||||
<div className="xl:hidden flex flex-wrap items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
meta={
|
||||
<div className="flex items-center gap-3">
|
||||
{builtInCluster && (
|
||||
<div className="hidden xl:flex items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns
|
||||
agent={agent}
|
||||
environment={resolveRenderedEnvironment(agent.id)}
|
||||
showEnvironment={showEnvironmentColumn}
|
||||
/>
|
||||
</div>
|
||||
secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")}
|
||||
secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{agent.status === "paused" && <span className="sm:hidden"><AgentStatusBadge status={agent.status} /></span>}
|
||||
{builtInCluster}
|
||||
{showEnvironmentColumn && <span className="text-xs text-muted-foreground" title={resolveRenderedEnvironment(agent.id).title}>
|
||||
{resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail}
|
||||
</span>}
|
||||
{agent.lastHeartbeatAt && <span className="text-xs text-muted-foreground" title="Last heartbeat">{relativeTime(agent.lastHeartbeatAt)}</span>}
|
||||
</div>
|
||||
}
|
||||
metaSpacerClassName="hidden xl:block"
|
||||
)}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -441,14 +405,7 @@ export function Agents() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
||||
<PageTabBar
|
||||
items={visibleTabItems}
|
||||
value={tab}
|
||||
onValueChange={(v) => navigate(`/agents/${v}`)}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
{!forceListView && (
|
||||
@@ -502,17 +459,13 @@ export function Agents() {
|
||||
)}
|
||||
|
||||
{/* List view */}
|
||||
{effectiveView === "list" && filtered.length > 0 && (
|
||||
<div>
|
||||
{filtered.map(renderAgentRow)}
|
||||
</div>
|
||||
{effectiveView === "list" && agents && agents.length > 0 && (
|
||||
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
|
||||
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
|
||||
visibleAgents={filtered} renderAgent={renderAgentRow} />
|
||||
)}
|
||||
|
||||
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No agents match the selected status.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Org chart view */}
|
||||
{effectiveView === "org" && filteredOrg.length > 0 && (
|
||||
@@ -527,8 +480,6 @@ export function Agents() {
|
||||
environmentByAgentId={environmentByAgentId}
|
||||
environmentDataLoading={environmentDataLoading}
|
||||
showEnvironment={showEnvironmentColumn}
|
||||
tab={tab}
|
||||
memberships={membershipsQuery.data}
|
||||
builtInByAgentId={builtInByAgentId}
|
||||
onConfigureBuiltIn={setConfigureState}
|
||||
/>
|
||||
@@ -571,8 +522,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId,
|
||||
environmentDataLoading,
|
||||
showEnvironment,
|
||||
tab,
|
||||
memberships,
|
||||
builtInByAgentId,
|
||||
onConfigureBuiltIn,
|
||||
}: {
|
||||
@@ -583,8 +532,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId: Map<string, EnvironmentDescriptor>;
|
||||
environmentDataLoading: boolean;
|
||||
showEnvironment: boolean;
|
||||
tab: FilterTab;
|
||||
memberships: ReturnType<typeof useResourceMemberships>["data"];
|
||||
builtInByAgentId: Map<string, BuiltInAgentState>;
|
||||
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
|
||||
}) {
|
||||
@@ -592,17 +539,12 @@ function OrgTreeNode({
|
||||
const builtInState = builtInByAgentId.get(node.id);
|
||||
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
|
||||
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
||||
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<Link
|
||||
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit",
|
||||
agent?.pausedAt && tab !== "paused" && "opacity-50",
|
||||
membershipState === "left" && "sm:text-foreground/55",
|
||||
)}
|
||||
className="group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit"
|
||||
>
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
@@ -689,8 +631,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId={environmentByAgentId}
|
||||
environmentDataLoading={environmentDataLoading}
|
||||
showEnvironment={showEnvironment}
|
||||
tab={tab}
|
||||
memberships={memberships}
|
||||
builtInByAgentId={builtInByAgentId}
|
||||
onConfigureBuiltIn={onConfigureBuiltIn}
|
||||
/>
|
||||
|
||||
@@ -74,6 +74,8 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ isMobile: mockSidebarState.isMobile }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/ai-connections", () => ({ aiConnectionsApi: { list: vi.fn(async () => ({ currentUserId: "board", canManageConnections: true, connections: [] })) } }));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
@@ -458,7 +460,7 @@ describe("Agents", () => {
|
||||
|
||||
// The heartbeat cell must render on a single line so full dates like
|
||||
// "Apr 30, 2026" never wrap (PAP-85 defect #2).
|
||||
const heartbeatCell = container.querySelector(".whitespace-nowrap.w-24");
|
||||
const heartbeatCell = container.querySelector('[title="Last heartbeat"]');
|
||||
expect(heartbeatCell).not.toBeNull();
|
||||
expect(heartbeatCell?.textContent).not.toContain("\n");
|
||||
});
|
||||
@@ -491,8 +493,6 @@ describe("Agents", () => {
|
||||
expect(orgViewport?.parentElement?.classList.contains("flex-1")).toBe(true);
|
||||
expect(orgViewport?.parentElement?.classList.contains("md:min-h-0")).toBe(true);
|
||||
expect(orgViewport?.parentElement?.classList.contains("h-(--sz-calc-38)")).toBe(false);
|
||||
expect(orgViewport?.closest('[data-slot="tabs"]')?.classList.contains("h-full")).toBe(true);
|
||||
expect(orgViewport?.closest('[role="tabpanel"]')?.classList.contains("flex-1")).toBe(true);
|
||||
expect(orgViewport?.parentElement?.parentElement?.classList.contains("min-h-0")).toBe(true);
|
||||
expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
|
||||
@@ -539,9 +539,9 @@ 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.\\@5xl\\:block")).not.toBeNull();
|
||||
|
||||
expect(row?.classList.contains("text-foreground/55")).toBe(false);
|
||||
expect(row?.classList.contains("sm:text-foreground/55")).toBe(true);
|
||||
expect(row?.classList.contains("sm:text-foreground/55")).toBe(false);
|
||||
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"),
|
||||
@@ -828,22 +828,31 @@ describe("Agents", () => {
|
||||
expect(container.querySelector('select[aria-label="Group agents"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("hides built-in agent surfaces while the experimental flag is disabled", async () => {
|
||||
mockRouterState.pathname = "/agents/builtin";
|
||||
it.each([
|
||||
{ mode: "streamlined", AgentList: Agents },
|
||||
{ mode: "production", AgentList: ProductionAgents },
|
||||
].flatMap(layout => ["active", "paused", "error", "builtin"].map(tab => ({ ...layout, tab }))))("redirects legacy /agents/$tab to the full $mode roster", async ({ tab, AgentList }) => {
|
||||
mockRouterState.pathname = `/agents/${tab}`;
|
||||
mockSidebarState.isMobile = true;
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({}), makeAgent({ id: "paused-agent", name: "Paused", status: "paused" }),
|
||||
makeAgent({ id: "error-agent", name: "Error", status: "error" }),
|
||||
]);
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
renderAgentsView(root, queryClient);
|
||||
root!.render(<QueryClientProvider client={queryClient}><ToastProvider><AgentList /></ToastProvider></QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockBuiltInAgentsApi.list).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Built-in");
|
||||
expect(container.querySelectorAll("tbody tr")).toHaveLength(3);
|
||||
expect(mockRouterState.navigate).toHaveBeenCalledWith("/agents/all", { replace: true });
|
||||
});
|
||||
|
||||
it("shows and filters built-in agents when the experimental flag is enabled", async () => {
|
||||
it("redirects the legacy built-in URL to all agents while loading enabled built-in metadata", async () => {
|
||||
mockRouterState.pathname = "/agents/builtin";
|
||||
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true }));
|
||||
const builtInAgent = makeAgent({
|
||||
@@ -884,44 +893,26 @@ describe("Agents", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("1 agent");
|
||||
expect(container.querySelectorAll("tbody tr")).toHaveLength(2);
|
||||
expect(mockBuiltInAgentsApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(container.textContent).toContain("Built-in");
|
||||
expect(container.textContent).toContain("Briefs Agent");
|
||||
expect(container.textContent).not.toContain("Regular Agent");
|
||||
expect(container.textContent).toContain("Regular Agent");
|
||||
expect(container.querySelector('[title="Ships with Paperclip"]')).toBeNull();
|
||||
expect(mockRouterState.navigate).not.toHaveBeenCalledWith("/agents/all", { replace: true });
|
||||
expect(mockRouterState.navigate).toHaveBeenCalledWith("/agents/all", { replace: true });
|
||||
});
|
||||
|
||||
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
|
||||
it("selects visible agents without navigating and exposes the harness edit action", async () => {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
renderAgentsView(root, queryClient);
|
||||
});
|
||||
await act(async () => { renderAgentsView(root, queryClient); });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// Keep the list view selected before checking its aligned metadata columns.
|
||||
const listToggle = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.querySelector("svg.lucide-list"),
|
||||
);
|
||||
expect(listToggle).toBeDefined();
|
||||
await act(async () => {
|
||||
listToggle!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// 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.
|
||||
// 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(".\\@5xl\\:w-56");
|
||||
expect(titleCell).not.toBeNull();
|
||||
expect(titleCell?.textContent).toContain("Alpha");
|
||||
expect(titleCell?.classList.contains("flex-1")).toBe(true);
|
||||
expect(container.querySelector(".min-w-\\(--sz-7rem\\)")).toBeNull();
|
||||
const checkbox = container.querySelector<HTMLButtonElement>('[aria-label="Select Alpha"]');
|
||||
expect(checkbox).not.toBeNull();
|
||||
await act(async () => { checkbox!.click(); });
|
||||
expect(container.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 selected");
|
||||
expect(container.textContent).toContain("Edit connection & model");
|
||||
expect(mockRouterState.navigate).not.toHaveBeenCalled();
|
||||
expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Harness", "Connection", "Default model"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -968,8 +959,16 @@ describe("Agents", () => {
|
||||
expect(container.querySelector('[aria-label="Star Alpha"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not dim left-membership agent names on mobile", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
it.each([
|
||||
{ mode: "streamlined", AgentList: Agents, isMobile: false },
|
||||
{ mode: "streamlined", AgentList: Agents, isMobile: true },
|
||||
{ mode: "production", AgentList: ProductionAgents, isMobile: false },
|
||||
{ mode: "production", AgentList: ProductionAgents, isMobile: true },
|
||||
])("keeps paused and left-membership agent names readable in $mode (mobile: $isMobile)", async ({ AgentList, isMobile }) => {
|
||||
mockSidebarState.isMobile = isMobile;
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ status: "paused", pausedAt: new Date("2026-01-02T00:00:00Z") }),
|
||||
]);
|
||||
mockResourceMembershipsApi.listMine.mockResolvedValue({
|
||||
projectMemberships: {},
|
||||
agentMemberships: {
|
||||
@@ -984,15 +983,19 @@ describe("Agents", () => {
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
renderAgentsView(root, queryClient);
|
||||
root!.render(<QueryClientProvider client={queryClient}><ToastProvider><AgentList /></ToastProvider></QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('button[aria-label="List view"]')?.click(); });
|
||||
await flushReact();
|
||||
|
||||
const row = findAgentRow(container, "Alpha");
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.classList.contains("text-foreground/55")).toBe(false);
|
||||
expect(row?.classList.contains("sm:text-foreground/55")).toBe(true);
|
||||
expect(row?.classList.contains("sm:text-foreground/55")).toBe(false);
|
||||
expect(row?.classList.contains("opacity-50")).toBe(false);
|
||||
expect(row?.querySelector(".sm\\:hidden")?.textContent).toContain("paused");
|
||||
});
|
||||
|
||||
it("keeps invalid-org-chain agents visible with a warning marker", async () => {
|
||||
|
||||
+27
-78
@@ -1,4 +1,5 @@
|
||||
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
|
||||
import { AgentBulkRoster } from "@/components/agents/AgentBulkRoster";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
@@ -23,15 +24,10 @@ import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { OrgChart } from "./OrgChart";
|
||||
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { AgentsToolbar } from "../components/AgentsToolbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, Bot } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
useResourceMemberships,
|
||||
} from "../hooks/useResourceMemberships";
|
||||
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
||||
|
||||
import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
@@ -50,14 +46,6 @@ import { AGENT_FILTER_TABS } from "../lib/agent-filter-tabs";
|
||||
export { AGENT_FILTER_TABS } from "../lib/agent-filter-tabs";
|
||||
type FilterTab = (typeof AGENT_FILTER_TABS)[number];
|
||||
|
||||
const AGENT_FILTER_TAB_ITEMS: { value: FilterTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "builtin", label: "Built-in" },
|
||||
];
|
||||
|
||||
function isFilterTab(value: string): value is FilterTab {
|
||||
return (AGENT_FILTER_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -223,12 +211,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const builtInAgentsEnabled = instanceSettings?.experimental.enableBuiltInAgents === true;
|
||||
const tab: FilterTab = requestedTab === "builtin" && !builtInAgentsEnabled ? "all" : requestedTab;
|
||||
const visibleTabItems = useMemo(
|
||||
() => AGENT_FILTER_TAB_ITEMS.filter((item) => item.value !== "builtin" || builtInAgentsEnabled),
|
||||
[builtInAgentsEnabled],
|
||||
);
|
||||
|
||||
const tab: FilterTab = "all";
|
||||
const { data: builtInAgents } = useQuery({
|
||||
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
|
||||
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
|
||||
@@ -287,7 +270,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
refetchInterval: sharedRuns.refetchInterval,
|
||||
});
|
||||
usePublishSharedQueryData(sharedRuns, runs, runsUpdatedAt);
|
||||
const membershipsQuery = useResourceMemberships(selectedCompanyId);
|
||||
|
||||
// Map agentId -> first live run + live run count
|
||||
const liveRunByAgent = useMemo(() => {
|
||||
@@ -331,10 +313,10 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCompanyId && requestedTab === "builtin" && instanceSettings && !builtInAgentsEnabled) {
|
||||
if (requestedTab !== "all") {
|
||||
navigate("/agents/all", { replace: true });
|
||||
}
|
||||
}, [builtInAgentsEnabled, instanceSettings, navigate, requestedTab, selectedCompanyId]);
|
||||
}, [navigate, requestedTab]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Bot} message="Select an organization to view agents." />;
|
||||
@@ -386,43 +368,28 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
titleAccessory={<PrimaryAgentIndicator agentId={agent.id} companyId={agent.companyId} />}
|
||||
titleClassName="flex-1 @5xl:flex-none @5xl:w-56"
|
||||
titleClassName="flex-1"
|
||||
titleTextClassName="truncate"
|
||||
subtitleClassName="truncate"
|
||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
className={cn(
|
||||
"group py-3",
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "sm:text-foreground/55" : "",
|
||||
)}
|
||||
className="group py-3"
|
||||
leading={hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : (
|
||||
<AgentAvatar agent={agent} size={32} />
|
||||
)}
|
||||
secondaryRow={builtInCluster && (
|
||||
<div className="@5xl:hidden flex flex-wrap items-center gap-1.5">
|
||||
secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")}
|
||||
secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{agent.status === "paused" && <span className="sm:hidden"><AgentStatusBadge status={agent.status} /></span>}
|
||||
{builtInCluster}
|
||||
{showEnvironmentColumn && <span className="text-xs text-muted-foreground" title={resolveRenderedEnvironment(agent.id).title}>
|
||||
{resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail}
|
||||
</span>}
|
||||
{agent.lastHeartbeatAt && <span className="text-xs text-muted-foreground" title="Last heartbeat">{relativeTime(agent.lastHeartbeatAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
meta={
|
||||
<div className="flex items-center gap-3">
|
||||
{builtInCluster && (
|
||||
<div className="hidden @5xl:flex items-center gap-1.5">
|
||||
{builtInCluster}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden @5xl:flex items-center gap-3">
|
||||
<AgentMetaColumns
|
||||
agent={agent}
|
||||
environment={resolveRenderedEnvironment(agent.id)}
|
||||
showEnvironment={showEnvironmentColumn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
metaSpacerClassName="hidden @5xl:block"
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{agentChat.enabled && <Button variant="ghost" size="sm" onClick={event => { event.preventDefault(); event.stopPropagation(); navigate(`/chats/${agentRouteRef(agent)}`); }}>Chat</Button>}
|
||||
@@ -445,17 +412,14 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={value => navigate(`/agents/${value}`)} className={cn(
|
||||
"@container",
|
||||
effectiveView === "org"
|
||||
? "flex h-full min-h-0 flex-col gap-4"
|
||||
: "gap-4",
|
||||
<div className={cn(
|
||||
"flex flex-col gap-4 @container",
|
||||
effectiveView === "org" && "h-full min-h-0",
|
||||
)}>
|
||||
<AgentsToolbar query={query} onQueryChange={setQuery} tab={tab} items={visibleTabItems}
|
||||
onTabChange={value => navigate(`/agents/${value}`)} view={effectiveView} onViewChange={setView}
|
||||
<AgentsToolbar query={query} onQueryChange={setQuery} view={effectiveView} onViewChange={setView}
|
||||
hideViewToggle={forceListView} onNewAgent={openNewAgent} />
|
||||
|
||||
<TabsContent value={tab} className={cn("min-h-0 space-y-4", effectiveView === "org" && "flex flex-1 flex-col")}>
|
||||
<div className={cn("min-h-0 space-y-4", effectiveView === "org" && "flex flex-1 flex-col")}>
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{agents && agents.length === 0 && (
|
||||
@@ -468,17 +432,13 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
)}
|
||||
|
||||
{/* List view */}
|
||||
{effectiveView === "list" && filtered.length > 0 && (
|
||||
<div>
|
||||
{filtered.map(renderAgentRow)}
|
||||
</div>
|
||||
{effectiveView === "list" && agents && agents.length > 0 && (
|
||||
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
|
||||
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
|
||||
visibleAgents={filtered} renderAgent={renderAgentRow} />
|
||||
)}
|
||||
|
||||
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No agents match your search and selected status.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Org chart view */}
|
||||
{effectiveView === "org" && filteredOrg.length > 0 && (
|
||||
@@ -508,8 +468,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,8 +481,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId,
|
||||
environmentDataLoading,
|
||||
showEnvironment,
|
||||
tab,
|
||||
memberships,
|
||||
builtInByAgentId,
|
||||
onConfigureBuiltIn,
|
||||
}: {
|
||||
@@ -533,8 +491,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId: Map<string, EnvironmentDescriptor>;
|
||||
environmentDataLoading: boolean;
|
||||
showEnvironment: boolean;
|
||||
tab: FilterTab;
|
||||
memberships: ReturnType<typeof useResourceMemberships>["data"];
|
||||
builtInByAgentId: Map<string, BuiltInAgentState>;
|
||||
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
|
||||
}) {
|
||||
@@ -542,17 +498,12 @@ function OrgTreeNode({
|
||||
const builtInState = builtInByAgentId.get(node.id);
|
||||
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
|
||||
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
||||
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<Link
|
||||
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit",
|
||||
agent?.pausedAt && tab !== "paused" && "opacity-50",
|
||||
membershipState === "left" && "sm:text-foreground/55",
|
||||
)}
|
||||
className="group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit"
|
||||
>
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
@@ -639,8 +590,6 @@ function OrgTreeNode({
|
||||
environmentByAgentId={environmentByAgentId}
|
||||
environmentDataLoading={environmentDataLoading}
|
||||
showEnvironment={showEnvironment}
|
||||
tab={tab}
|
||||
memberships={memberships}
|
||||
builtInByAgentId={builtInByAgentId}
|
||||
onConfigureBuiltIn={onConfigureBuiltIn}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Routes, Route, useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { Agents } from "@/pages/Agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type { Agent, AiConnectionBinding, AiManagedConnectionSummary } from "@paperclipai/shared";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
import { connections, harnesses, roster } from "../../support/design-proposals/bulk-agent-settings-data";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const uuid = (n: number) => `10000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
const accounts: AiManagedConnectionSummary[] = connections.map((c, i) => ({ ...c, id: uuid(i + 1), grantId: uuid(i + 101), isDefault: false,
|
||||
...(c.id === "codex-limited" ? { routing: { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://models.example.invalid/v1", models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }] } as const } : {}),
|
||||
}));
|
||||
function initialAgents(): Agent[] {
|
||||
return roster.map((r, i) => {
|
||||
const connection = accounts[connections.findIndex(c => c.id === r.connectionId)];
|
||||
const binding: AiConnectionBinding | undefined = connection ? { provider: connection.provider, method: connection.method, mode: "shared", connectionId: connection.id, grantId: connection.grantId } : undefined;
|
||||
return { ...storybookAgents[0], id: uuid(i + 201), urlKey: r.id, name: r.name, title: r.title, companyId,
|
||||
adapterType: r.harness === "external" ? "http" : harnesses[r.harness].adapter,
|
||||
adapterConfig: { model: r.model, cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 }, ...(binding ? { aiConnection: binding } : {}) }, status: r.paused ? "paused" : "idle", pausedAt: r.paused ? new Date("2026-10-22T00:00:00Z") : null };
|
||||
});
|
||||
}
|
||||
|
||||
function BulkSettingsPage({ failOne = false, localHermes = false }: { failOne?: boolean; localHermes?: boolean }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { setSelectedCompanyId } = useCompany();
|
||||
useEffect(() => {
|
||||
let agents = localHermes ? initialAgents().slice(0, 1).map(a => ({ ...a, name: "Hermes", adapterType: "hermes_local" as const, runtimeConfig: {}, adapterConfig: { model: "provider/old-model" } })) : initialAgents();
|
||||
let failed = false;
|
||||
const previous = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
|
||||
const path = url.pathname;
|
||||
if (path === `/api/companies/${companyId}/agents`) return Response.json(agents);
|
||||
if (path === `/api/companies/${companyId}/ai-connections`) return Response.json({ currentUserId: "user-board", canManageConnections: true, connections: accounts, pools: [] });
|
||||
const adapter = path.match(/\/adapters\/([^/]+)\/models$/)?.[1];
|
||||
if (adapter) return Response.json(Object.values(harnesses).find(h => h.adapter === adapter)?.models.map(id => ({ id, label: id })) ?? []);
|
||||
const agentId = path.match(/^\/api\/agents\/([^/]+)$/)?.[1];
|
||||
if (agentId) {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
if (!agent) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
if (init?.method === "PATCH") {
|
||||
if (failOne && agent.name === "Scout" && !failed) { failed = true; return Response.json({ error: "Connection interrupted while saving. Try again." }, { status: 503 }); }
|
||||
const { runtimeSettings } = JSON.parse(String(init.body));
|
||||
const updated = { ...agent, adapterConfig: { ...agent.adapterConfig, ...(runtimeSettings.model ? { model: runtimeSettings.model } : {}) }, runtimeConfig: { ...agent.runtimeConfig, ...(runtimeSettings.aiConnection ? { aiConnection: runtimeSettings.aiConnection } : {}) } };
|
||||
agents = agents.map(a => a.id === agentId ? updated : a);
|
||||
return Response.json(updated);
|
||||
}
|
||||
return Response.json(agent);
|
||||
}
|
||||
return previous(input, init);
|
||||
};
|
||||
client.setQueryData(queryKeys.agents.list(companyId), agents);
|
||||
setSelectedCompanyId(companyId);
|
||||
navigate("/PAP/agents/all", { replace: true });
|
||||
setReady(true);
|
||||
return () => { window.fetch = previous; };
|
||||
}, [client, navigate, setSelectedCompanyId, failOne, localHermes]);
|
||||
return ready ? <PluginLauncherProvider><Routes><Route path="/:companyPrefix" element={<Layout />}><Route path="agents/all" element={<Agents />} /></Route></Routes></PluginLauncherProvider> : null;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "agents-bulk-settings", title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Bulk settings",
|
||||
component: BulkSettingsPage,
|
||||
parameters: { layout: "fullscreen", initialEntries: ["/PAP/agents/all"], paperclip: {
|
||||
capabilities: ["agents/organization", "agents/instructions"], implementation: "production", boundary: "fixtures",
|
||||
notes: "Actual Agents page, filter icon/popover with preserved selection, bulk roster, sheet, account picker, model intersection and narrow PATCH client. Includes paused agents at normal contrast. The roster has no status selector or persistent selection hint. HTTP accounts/catalogs/writes are synthetic, including one transient failure and an empty local Hermes catalog. Does not establish live provider credential validity or database persistence. The original approved proposal remains in Design proposals.",
|
||||
} },
|
||||
} satisfies Meta<typeof BulkSettingsPage>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Playground: Story = {};
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
|
||||
export const FilterJourney: Story = { play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await page.findByRole("checkbox", { name: "Select Sage" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Filter agents" }));
|
||||
const filters = within(await screen.findByRole("dialog", { name: "Agent filters" }));
|
||||
await userEvent.click(filters.getByRole("button", { name: "Codex" }));
|
||||
await expect(page.queryByRole("checkbox", { name: "Select Sage" })).not.toBeInTheDocument();
|
||||
await expect(page.getByText("1 hidden by filters")).toBeVisible();
|
||||
await expect(page.getByRole("checkbox", { name: "Select Scout" })).toBeEnabled();
|
||||
await userEvent.click(filters.getByRole("button", { name: "Clear" }));
|
||||
await expect(await page.findByRole("checkbox", { name: "Select Sage" })).toBeChecked();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await expect(page.getByRole("button", { name: "Filter agents" })).toHaveFocus();
|
||||
await userEvent.click(page.getByRole("button", { name: "Clear selection" }));
|
||||
} };
|
||||
|
||||
async function selectCodex(canvasElement: HTMLElement, mixed = false) {
|
||||
const page = within(canvasElement);
|
||||
for (const name of ["Atlas", "Nova", "Pixel", "Scout", ...(mixed ? ["Sage"] : [])]) await userEvent.click(await page.findByRole("checkbox", { name: `Select ${name}` }));
|
||||
await userEvent.click(page.getByRole("button", { name: mixed ? "Edit 4 Codex" : "Edit connection & model" }));
|
||||
return within(canvasElement.ownerDocument.body);
|
||||
}
|
||||
export const ModelJourney: Story = { play: async ({ canvasElement }) => {
|
||||
const screen = await selectCodex(canvasElement);
|
||||
await userEvent.click(await screen.findByRole("checkbox", { name: "Change default model" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "gpt-5.5" }));
|
||||
await expect(screen.getByRole("button", { name: "Update 3 agents" })).toBeEnabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 3 agents" }));
|
||||
await expect(await within(canvasElement).findByText("Updated 3 Codex agents.")).toBeVisible();
|
||||
const scout = within(canvasElement).getByRole("checkbox", { name: "Select Scout" }).closest("tr")!;
|
||||
await expect(scout).toHaveTextContent("gpt-5.5");
|
||||
await expect(scout).toHaveTextContent("OpenAI · Engineering");
|
||||
} };
|
||||
export const ConnectionJourney: Story = { play: async ({ canvasElement }) => {
|
||||
const screen = await selectCodex(canvasElement, true);
|
||||
await expect(await screen.findByText("1 other selected agent won’t change.")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" }));
|
||||
await userEvent.click(await screen.findByRole("combobox", { name: "Connection" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" }));
|
||||
await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" }));
|
||||
await expect(await within(canvasElement).findByText("Updated 4 Codex agents.")).toBeVisible();
|
||||
await expect(within(canvasElement).getByRole("checkbox", { name: "Select Sage" })).toBeChecked();
|
||||
await expect(within(canvasElement).getByRole("checkbox", { name: "Select Scout" }).closest("tr")!).toHaveTextContent("gpt-5.3-codex");
|
||||
} };
|
||||
export const RetryJourney: Story = { args: { failOne: true }, play: async ({ canvasElement }) => {
|
||||
const screen = await selectCodex(canvasElement);
|
||||
await userEvent.click(await screen.findByRole("checkbox", { name: "Change connection" }));
|
||||
await userEvent.click(await screen.findByRole("combobox", { name: "Connection" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" }));
|
||||
await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" }));
|
||||
await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Retry 1 agent" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry 1 agent" }));
|
||||
await expect(await within(canvasElement).findByText("Updated 4 Codex agents.")).toBeVisible();
|
||||
} };
|
||||
|
||||
export const ManualModelJourney: Story = { args: { localHermes: true }, play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("checkbox", { name: "Select Hermes" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Edit connection & model" }));
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await screen.findByRole("checkbox", { name: "Change default model" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
await userEvent.type(await screen.findByPlaceholderText("Search models... (type to create)"), "provider/custom-model");
|
||||
await userEvent.click(screen.getByRole("option", { name: "Use manual model provider/custom-model" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Update 1 agent" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 1 agent" }));
|
||||
await expect(await page.findByText("Updated 1 Hermes agent.")).toBeVisible();
|
||||
await expect(page.getByRole("checkbox", { name: "Select Hermes" }).closest("tr")!).toHaveTextContent("provider/custom-model");
|
||||
} };
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { BulkAgentSettingsProposal } from "../../support/design-proposals/BulkAgentSettingsProposal";
|
||||
|
||||
const meta = {
|
||||
id: "design-bulk-agent-settings",
|
||||
title: "Design proposals/Pages/Agents/Bulk connection and model",
|
||||
component: BulkAgentSettingsProposal,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: ["/PAP/agents/all"],
|
||||
docs: { description: { component: "Start in Playground: filter by harness, select rows, then Edit connection & model. Mixed selections offer a separate action for each harness. Each field is opt-in, with a per-agent before/after preview and a count excluding agents already at the target value. Connection selection uses the production AiConnectionSelect; ModelDropdown offers the intersection of fixture model availability. Incompatible models block saving without silently replacing them. Save updates local roster state, and Partial failure allows retry of only the unsuccessful agent. All accounts, catalogs, saves and failures are synthetic. Future-run timing and preserving task overrides are proposed behavior, not production acceptance. Reload/reset the story to restore its fixtures." } },
|
||||
paperclip: {
|
||||
capabilities: ["agents/instructions", "agents/organization"],
|
||||
home: "design-proposals", status: "proposed",
|
||||
exception: "Requested interactive bulk-settings design for review before production implementation.",
|
||||
implementation: "prototype", boundary: "fixtures",
|
||||
notes: "Reuses DesignPageShell, DesignSidebar, AgentsToolbar, AgentAvatar, AiConnectionSelect, ModelDropdown, Checkbox and Sheet. Local roster, selection, harness grouping, model intersections, validation and save/retry state are prototype-only. No API mutations, account creation, grants or agent runs. Model catalogs are synthetic, not live provider promises. External agents cannot be selected. Filtering preserves selection and names hidden selections. Checked fields alone change; no-op agents are excluded. Dark/light toolbar and responsive sheet available. Backend authorization, grant availability for every agent, concurrency/revision checks, durable partial receipts and execution/session semantics require implementation after design review.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BulkAgentSettingsProposal>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Playground: Story = {};
|
||||
export const CodexSelected: Story = { args: { selection: "codex" } };
|
||||
export const MixedHarnesses: Story = { args: { selection: "mixed" } };
|
||||
export const ChangeConnection: Story = { args: { selection: "codex", editor: "connection" } };
|
||||
export const ChangeDefaultModel: Story = { args: { selection: "codex", editor: "model" } };
|
||||
export const ChangeBoth: Story = { args: { selection: "codex", editor: "both" } };
|
||||
export const IncompatibleModel: Story = { args: { selection: "codex", editor: "incompatible" } };
|
||||
export const PartialFailure: Story = { args: { selection: "codex", editor: "connection", failOne: true } };
|
||||
export const Light: Story = { args: { selection: "mixed" }, globals: { theme: "light" } };
|
||||
export const MobileEditor: Story = { args: { selection: "codex", editor: "both" }, globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
|
||||
export const ConnectionJourney: Story = {
|
||||
args: { selection: "mixed" },
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await step("Mixed selection explicitly scopes the change to Codex", async () => {
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Edit 4 Codex" }));
|
||||
await expect(screen.getByText("2 other selected agents won’t change.")).toBeVisible();
|
||||
await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeDisabled();
|
||||
});
|
||||
await step("Changing only the connection keeps every model", async () => {
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Connection" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" }));
|
||||
await expect(await canvas.findByText("Updated 4 Codex agents.")).toBeVisible();
|
||||
await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("gpt-5.3-codex");
|
||||
await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("OpenAI · Production");
|
||||
await expect(canvas.getByTestId("agent-row-sage")).toHaveTextContent("Anthropic · Team");
|
||||
await expect(canvas.getByText("2 selected")).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ModelJourney: Story = {
|
||||
args: { selection: "codex", editor: "model" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Model" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "gpt-5.5" }));
|
||||
await expect(screen.getByText("1 already has these settings.")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 3 agents" }));
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText("Updated 3 Codex agents.")).toBeVisible();
|
||||
await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("gpt-5.5");
|
||||
await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("OpenAI · Engineering");
|
||||
},
|
||||
};
|
||||
|
||||
export const CompatibilityJourney: Story = {
|
||||
args: { selection: "codex", editor: "incompatible" },
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await step("A connection change cannot silently replace an incompatible model", async () => {
|
||||
await expect(await screen.findByText("Scout needs a compatible model")).toBeVisible();
|
||||
await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Change default model" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
await expect(screen.queryByRole("option", { name: "gpt-5.3-codex" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("option", { name: "gpt-5.5" }));
|
||||
await expect(screen.queryByText("Scout needs a compatible model")).not.toBeInTheDocument();
|
||||
await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled();
|
||||
});
|
||||
await step("Cancel leaves both settings untouched", async () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
const row = within(canvasElement).getByTestId("agent-row-scout");
|
||||
await expect(row).toHaveTextContent("OpenAI · Engineering");
|
||||
await expect(row).toHaveTextContent("gpt-5.3-codex");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const RetryJourney: Story = {
|
||||
args: { selection: "codex", editor: "connection", failOne: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const screen = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await screen.findByRole("combobox", { name: "Connection" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" }));
|
||||
await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible();
|
||||
await expect(within(canvasElement).getByTestId("agent-row-atlas")).toHaveTextContent("OpenAI · Production");
|
||||
await expect(within(canvasElement).getByTestId("agent-row-scout")).toHaveTextContent("OpenAI · Engineering");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry 1 agent" }));
|
||||
await expect(await within(canvasElement).findByText("Updated 4 Codex agents.")).toBeVisible();
|
||||
await expect(within(canvasElement).getByTestId("agent-row-scout")).toHaveTextContent("OpenAI · Production");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, Check, Loader2, Pencil, X } from "lucide-react";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { AgentsToolbar } from "@/components/AgentsToolbar";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { ModelDropdown } from "@/components/AgentConfigForm";
|
||||
import { AiConnectionSelect } from "@/components/ai-connections/AiConnectionSelect";
|
||||
import { bindingProblem, type AiConnectionBinding } from "@/components/ai-connections/model";
|
||||
import { DesignPageShell } from "@/components/DesignPageShell";
|
||||
import { DesignSidebar } from "@/components/DesignSidebar";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DesignProposalAccess } from "./DesignProposalAccess";
|
||||
import { bindingFor, connectionName, connections, harnesses, modelsFor, roster, type Harness, type RosterAgent } from "./bulk-agent-settings-data";
|
||||
|
||||
export interface BulkAgentSettingsProposalProps {
|
||||
selection?: "none" | "codex" | "mixed";
|
||||
editor?: "closed" | "connection" | "model" | "both" | "incompatible";
|
||||
failOne?: boolean;
|
||||
}
|
||||
|
||||
function HarnessLabel({ harness }: { harness: RosterAgent["harness"] }) {
|
||||
if (harness === "external") return <span className="text-muted-foreground">External</span>;
|
||||
const info = harnesses[harness];
|
||||
return <span className="inline-flex items-center gap-2"><AppLogo name={info.label} brandKey={info.brand} size={16} compact />{info.label}</span>;
|
||||
}
|
||||
|
||||
function BulkEditor({ agents, harness, totalSelected, initialEditor, failOne, saving, setSaving, onApply, onDone }: {
|
||||
agents: RosterAgent[];
|
||||
harness: Harness;
|
||||
totalSelected: number;
|
||||
initialEditor: BulkAgentSettingsProposalProps["editor"];
|
||||
failOne: boolean;
|
||||
saving: boolean;
|
||||
setSaving: (saving: boolean) => void;
|
||||
onApply: (ids: string[], change: { connectionId?: string; model?: string }) => void;
|
||||
onDone: (message?: string) => void;
|
||||
}) {
|
||||
const info = harnesses[harness];
|
||||
const [changeConnection, setChangeConnection] = useState(initialEditor === "connection" || initialEditor === "both" || initialEditor === "incompatible");
|
||||
const [changeModel, setChangeModel] = useState(initialEditor === "model" || initialEditor === "both");
|
||||
const [binding, setBinding] = useState<AiConnectionBinding | undefined>(initialEditor === "incompatible" ? bindingFor("codex-limited") : undefined);
|
||||
const [model, setModel] = useState("");
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [failedIds, setFailedIds] = useState<string[] | null>(null);
|
||||
const [savedCount, setSavedCount] = useState(0);
|
||||
const [accountNotice, setAccountNotice] = useState(false);
|
||||
const targetConnection = binding?.mode === "responsible_user" ? "responsible_user" : binding?.connectionId;
|
||||
const targets = failedIds ? agents.filter(agent => failedIds.includes(agent.id)) : agents;
|
||||
const availableModels = info.models.filter(id => targets.every(agent => modelsFor(harness, changeConnection && targetConnection ? targetConnection : agent.connectionId).includes(id)));
|
||||
const invalid = targets.filter(agent => !modelsFor(harness, changeConnection && targetConnection ? targetConnection : agent.connectionId).includes(changeModel ? model : agent.model));
|
||||
const changed = targets.filter(agent => (changeConnection && targetConnection && targetConnection !== agent.connectionId) || (changeModel && model && model !== agent.model));
|
||||
const missing = (changeConnection && !targetConnection) || (changeModel && !model);
|
||||
const connectionError = changeConnection && binding && bindingProblem(binding, { companyId: "company-storybook", provider: info.provider }, connections, "user-board", agents[0].id);
|
||||
const canSave = changed.length > 0 && !missing && !connectionError && invalid.length === 0 && !saving;
|
||||
const otherCount = totalSelected - agents.length;
|
||||
const currentConnections = [...new Set(agents.map(agent => connectionName(agent.connectionId)))];
|
||||
const currentModels = [...new Set(agents.map(agent => agent.model))];
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
// Local fixture latency only. No HTTP mutation is made by this proposal.
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
const failures = failOne && failedIds === null ? [changed.at(-1)!.id] : [];
|
||||
const successes = changed.filter(agent => !failures.includes(agent.id));
|
||||
onApply(successes.map(agent => agent.id), { connectionId: changeConnection ? targetConnection : undefined, model: changeModel ? model : undefined });
|
||||
setSaving(false);
|
||||
if (failures.length) {
|
||||
setSavedCount(successes.length);
|
||||
setFailedIds(failures);
|
||||
} else {
|
||||
onDone(`Updated ${savedCount + successes.length} ${info.label} agents.`);
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<SheetHeader className="px-6 pt-6 pb-0">
|
||||
<SheetTitle>Update {agents.length} {info.label} agents</SheetTitle>
|
||||
<SheetDescription>{otherCount > 0 ? `${otherCount} other selected agents won’t change.` : "Change their connection, default model, or both."}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-2">
|
||||
<div className="flex flex-wrap gap-2" aria-label="Agents to update">
|
||||
{agents.map(agent => <span key={agent.id} className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs"><AgentAvatar agent={agent} size={20} />{agent.name}</span>)}
|
||||
</div>
|
||||
{failedIds && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm">
|
||||
<p className="font-medium">{savedCount} updated · {failedIds.length} couldn’t be saved</p>
|
||||
<p className="text-muted-foreground">{targets.map(agent => agent.name).join(", ")}: connection interrupted while saving. Retry updates only this agent.</p>
|
||||
</div>}
|
||||
<fieldset disabled={saving || failedIds !== null} className="flex flex-col gap-6 disabled:opacity-60">
|
||||
<section className="space-y-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox checked={changeConnection} onCheckedChange={value => setChangeConnection(value === true)} aria-label="Change connection" className="mt-0.5" />
|
||||
<span className="space-y-1"><span className="block text-sm font-medium">Change connection</span><span className="block text-xs text-muted-foreground">{currentConnections.length === 1 ? currentConnections[0] : `${currentConnections.length} different connections`}{!changeConnection && " · Keep current"}</span></span>
|
||||
</label>
|
||||
{changeConnection && <div className="space-y-2 pl-7">
|
||||
<AiConnectionSelect requirement={{ companyId: "company-storybook", provider: info.provider }} connections={connections}
|
||||
value={binding} currentUserId="user-board" agentId={agents[0].id} agentName={`${agents.length} agents`} adapterType={info.adapter}
|
||||
onChange={setBinding} onConnect={() => setAccountNotice(true)} />
|
||||
{accountNotice && <p role="status" className="text-xs text-muted-foreground">Account setup is outside this preview. Choose one of the sample connections.</p>}
|
||||
</div>}
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox checked={changeModel} onCheckedChange={value => setChangeModel(value === true)} aria-label="Change default model" className="mt-0.5" />
|
||||
<span className="space-y-1"><span className="block text-sm font-medium">Change default model</span><span className="block text-xs text-muted-foreground">{currentModels.length === 1 ? currentModels[0] : `${currentModels.length} different models`}{!changeModel && " · Keep current"}</span></span>
|
||||
</label>
|
||||
{changeModel && <div className="pl-7"><ModelDropdown models={availableModels.map(id => ({ id, label: id }))} value={model} onChange={setModel}
|
||||
open={modelOpen} onOpenChange={setModelOpen} allowDefault={false} required groupByProvider={false} preserveOrder />
|
||||
<p className="pt-2 text-xs text-muted-foreground">Available on every selected agent’s {changeConnection ? "new connection" : "current connection"}.</p>
|
||||
</div>}
|
||||
</section>
|
||||
</fieldset>
|
||||
{!missing && invalid.length > 0 && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm">
|
||||
<p className="font-medium">{invalid.map(agent => agent.name).join(", ")} needs a compatible model</p>
|
||||
<p className="text-muted-foreground">{changeModel ? model : invalid[0].model} isn’t available on the selected connection. Choose a different connection or change the default model.</p>
|
||||
{!changeModel && <Button size="sm" variant="outline" onClick={() => setChangeModel(true)}>Change default model</Button>}
|
||||
</div>}
|
||||
{!missing && changed.length > 0 && <section className="space-y-3" aria-label="Changes to apply">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">CHANGES TO APPLY</h3>
|
||||
<div className="divide-y divide-border">{changed.map(agent => <div key={agent.id} className="space-y-1.5 py-3 text-xs">
|
||||
<p className="text-sm font-medium">{agent.name}</p>
|
||||
{changeConnection && targetConnection !== agent.connectionId && <p className="flex flex-wrap items-center gap-1.5 text-muted-foreground"><span>{connectionName(agent.connectionId)}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{connectionName(targetConnection!)}</span></p>}
|
||||
{changeModel && model !== agent.model && <p className="flex flex-wrap items-center gap-1.5 font-mono text-muted-foreground"><span>{agent.model}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{model}</span></p>}
|
||||
</div>)}</div>
|
||||
{targets.length > changed.length && <p className="text-xs text-muted-foreground">{targets.length - changed.length} already {targets.length - changed.length === 1 ? "has" : "have"} these settings.</p>}
|
||||
</section>}
|
||||
{!missing && changed.length === 0 && (changeConnection || changeModel) && <p className="text-sm text-muted-foreground">All selected agents already have these settings.</p>}
|
||||
<p className="text-xs text-muted-foreground">Applies to future runs. Running work continues with its current settings. Task-specific model overrides stay in place.</p>
|
||||
</div>
|
||||
<SheetFooter className="flex-row items-center justify-between border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={() => onDone()} disabled={saving}>{failedIds ? "Close" : "Cancel"}</Button>
|
||||
<Button onClick={() => void save()} disabled={!canSave}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Updating…" : failedIds ? `Retry ${failedIds.length} agent` : `Update ${changed.length || agents.length} agents`}</Button>
|
||||
</SheetFooter>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Proposal({ selection = "none", editor = "closed", failOne = false }: BulkAgentSettingsProposalProps) {
|
||||
const initialSelection = selection === "none" && editor !== "closed" ? "codex" : selection;
|
||||
const [agents, setAgents] = useState(roster);
|
||||
const [selected, setSelected] = useState<string[]>(roster.filter(agent => initialSelection === "mixed" ? agent.harness === "codex" || agent.harness === "claude" : initialSelection === "codex" && agent.harness === "codex").map(agent => agent.id));
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [editing, setEditing] = useState<Harness | null>(editor === "closed" ? null : "codex");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const visible = agents.filter(agent => (filter === "all" || agent.harness === filter) && (status === "all" || (status === "paused" ? agent.paused : !agent.paused)) && `${agent.name} ${agent.title}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const selectable = visible.filter(agent => agent.harness !== "external");
|
||||
const selectedAgents = agents.filter(agent => selected.includes(agent.id));
|
||||
const groups = (Object.keys(harnesses) as Harness[]).filter(harness => selectedAgents.some(agent => agent.harness === harness));
|
||||
const checked = selectable.length > 0 && selectable.every(agent => selected.includes(agent.id));
|
||||
const someChecked = selectable.some(agent => selected.includes(agent.id));
|
||||
const hiddenCount = selectedAgents.filter(agent => !visible.includes(agent)).length;
|
||||
const toggleAll = () => setSelected(checked ? selected.filter(id => !selectable.some(agent => agent.id === id)) : [...new Set([...selected, ...selectable.map(agent => agent.id)])]);
|
||||
const close = (message?: string) => {
|
||||
setEditing(null);
|
||||
if (message) {
|
||||
setNotice(message);
|
||||
setSelected(selected.filter(id => agents.find(agent => agent.id === id)?.harness !== editing));
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="[&_.design-page-navigation]:hidden md:[&_.design-page-navigation]:flex">
|
||||
<DesignPageShell title="Agents" sidebarStorageKey="paperclip.proposal.bulk-agents.sidebar" sidebar={<DesignSidebar onNewTask={() => setNotice("This preview focuses on bulk agent settings.")} />}
|
||||
headerActions={<span className="text-xs text-muted-foreground">Interactive proposal · sample data</span>}>
|
||||
<Tabs value={status} onValueChange={setStatus} className="gap-4">
|
||||
<AgentsToolbar query={query} onQueryChange={setQuery} view="list" onViewChange={() => {}} hideViewToggle onNewAgent={() => setNotice("Agent creation is outside this preview.")} />
|
||||
<PageTabBar items={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }]} value={status} onValueChange={setStatus} align="start" />
|
||||
<TabsContent value={status} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Select value={filter} onValueChange={setFilter}><SelectTrigger aria-label="Filter by harness" className="w-44"><SelectValue /></SelectTrigger><SelectContent>
|
||||
<SelectItem value="all">All harnesses</SelectItem>{(Object.keys(harnesses) as Harness[]).map(harness => <SelectItem key={harness} value={harness}>{harnesses[harness].label}</SelectItem>)}
|
||||
<SelectItem value="external">External</SelectItem>
|
||||
</SelectContent></Select>
|
||||
<span className="text-xs text-muted-foreground">{visible.length} agents</span>
|
||||
</div>
|
||||
{selected.length > 0 && <div role="region" aria-label="Bulk actions" className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-md border bg-muted p-3">
|
||||
<span className="text-sm font-medium">{selected.length} selected</span>
|
||||
{hiddenCount > 0 && <span className="text-xs text-muted-foreground">{hiddenCount} hidden by filters</span>}
|
||||
{groups.length > 1 && <span className="text-xs text-muted-foreground">Choose a harness to edit</span>}
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(harness => <Button key={harness} size="sm" variant="outline" onClick={() => setEditing(harness)}>
|
||||
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${selectedAgents.filter(agent => agent.harness === harness).length} ${harnesses[harness].label}`}
|
||||
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => setSelected([])}><X className="size-4" /></Button></div>
|
||||
</div>}
|
||||
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
|
||||
<div className="overflow-x-auto"><table aria-label="Agent roster" className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr><th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={checked ? true : someChecked ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th><th>Agent</th><th>Harness</th><th>Connection</th><th>Default model</th></tr></thead>
|
||||
<tbody className="[&_td]:px-3 [&_td]:py-3 [&_td]:whitespace-nowrap">{visible.map(agent => <tr className="border-t border-border data-[state=selected]:bg-accent/30" key={agent.id} data-testid={`agent-row-${agent.id}`} data-state={selected.includes(agent.id) ? "selected" : undefined}>
|
||||
<td><Checkbox aria-label={`Select ${agent.name}`} checked={selected.includes(agent.id)} disabled={agent.harness === "external"} onCheckedChange={value => setSelected(value ? [...selected, agent.id] : selected.filter(id => id !== agent.id))} /></td>
|
||||
<td><div className="flex items-center gap-3"><AgentAvatar agent={agent} size={32} /><div className="space-y-0.5"><span className="text-sm font-medium">{agent.name}</span><p className="text-xs text-muted-foreground">{agent.title}{agent.paused ? " · Paused" : ""}</p></div></div></td>
|
||||
<td className="text-xs"><HarnessLabel harness={agent.harness} /></td>
|
||||
<td className={cn("text-xs", !agent.connectionId && "text-muted-foreground")}>{agent.connectionId ? connectionName(agent.connectionId) : "Managed externally"}</td>
|
||||
<td className="font-mono text-xs">{agent.model || "—"}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
{!visible.length && <p className="py-8 text-center text-sm text-muted-foreground">No agents match these filters.</p>}
|
||||
{!selected.length && <p className="text-xs text-muted-foreground">Select agents to update their connection or default model.</p>}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DesignPageShell>
|
||||
<Sheet open={editing !== null} onOpenChange={open => { if (!open && !saving) close(); }}><SheetContent className="w-full sm:max-w-lg" showCloseButton={!saving}>
|
||||
{editing && <BulkEditor key={editing} agents={selectedAgents.filter(agent => agent.harness === editing)} harness={editing} totalSelected={selected.length} initialEditor={editor} failOne={failOne} saving={saving} setSaving={setSaving}
|
||||
onApply={(ids, change) => setAgents(current => current.map(agent => ids.includes(agent.id) ? { ...agent, connectionId: change.connectionId ?? agent.connectionId, model: change.model ?? agent.model } : agent))} onDone={close} />}
|
||||
</SheetContent></Sheet>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function BulkAgentSettingsProposal(props: BulkAgentSettingsProposalProps) {
|
||||
return <DesignProposalAccess><Proposal {...props} /></DesignProposalAccess>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AiConnectionBinding, AiConnectionSummary } from "@/components/ai-connections/model";
|
||||
|
||||
export type Harness = "codex" | "claude" | "gemini";
|
||||
export const harnesses = {
|
||||
codex: { label: "Codex", adapter: "codex_local", provider: "openai", brand: "codex", models: ["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"] },
|
||||
claude: { label: "Claude Code", adapter: "claude_local", provider: "anthropic", brand: "claude", models: ["claude-opus-5-5", "claude-sonnet-4-6"] },
|
||||
gemini: { label: "Gemini CLI", adapter: "gemini_local", provider: "google", brand: "gemini", models: ["gemini-3.1-pro-preview", "gemini-3-flash-preview"] },
|
||||
} as const;
|
||||
|
||||
function connection(id: string, name: string, provider: AiConnectionSummary["provider"], status: AiConnectionSummary["status"] = "connected"): AiConnectionSummary {
|
||||
return { id, name, provider, status, companyId: "company-storybook", grantId: `grant-${id}`, method: "api_key", ownership: "shared" };
|
||||
}
|
||||
|
||||
// Synthetic accounts and model availability; no provider requests or credentials.
|
||||
export const connections = [
|
||||
connection("codex-team", "OpenAI · Engineering", "openai"),
|
||||
connection("codex-new", "OpenAI · Production", "openai"),
|
||||
connection("codex-limited", "OpenAI · Restricted models", "openai"),
|
||||
connection("codex-expired", "OpenAI · Old account", "openai", "expired"),
|
||||
connection("claude-team", "Anthropic · Team", "anthropic"),
|
||||
connection("claude-new", "Anthropic · Production", "anthropic"),
|
||||
connection("gemini-team", "Google · Team", "google"),
|
||||
];
|
||||
|
||||
export function bindingFor(id: string): AiConnectionBinding {
|
||||
const item = connections.find(connection => connection.id === id)!;
|
||||
return { provider: item.provider, method: item.method, mode: "shared", connectionId: item.id, grantId: item.grantId };
|
||||
}
|
||||
|
||||
export function connectionName(id: string) {
|
||||
return id === "responsible_user" ? "Responsible user’s default" : connections.find(connection => connection.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
export function modelsFor(harness: Harness, connectionId: string): readonly string[] {
|
||||
return connectionId === "codex-limited" ? ["gpt-5.5", "gpt-5.4"] : harnesses[harness].models;
|
||||
}
|
||||
|
||||
export interface RosterAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
harness: Harness | "external";
|
||||
connectionId: string;
|
||||
model: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export const roster: RosterAgent[] = [
|
||||
{ id: "atlas", name: "Atlas", title: "Engineering lead", harness: "codex", connectionId: "codex-team", model: "gpt-5.4" },
|
||||
{ id: "nova", name: "Nova", title: "Product engineer", harness: "codex", connectionId: "codex-team", model: "gpt-5.4" },
|
||||
{ id: "pixel", name: "Pixel", title: "Frontend engineer", harness: "codex", connectionId: "codex-team", model: "gpt-5.5" },
|
||||
{ id: "scout", name: "Scout", title: "QA engineer", harness: "codex", connectionId: "codex-team", model: "gpt-5.3-codex", paused: true },
|
||||
{ id: "sage", name: "Sage", title: "Research lead", harness: "claude", connectionId: "claude-team", model: "claude-opus-5-5" },
|
||||
{ id: "mira", name: "Mira", title: "Product strategist", harness: "claude", connectionId: "claude-team", model: "claude-sonnet-4-6" },
|
||||
{ id: "orbit", name: "Orbit", title: "Market analyst", harness: "gemini", connectionId: "gemini-team", model: "gemini-3.1-pro-preview" },
|
||||
{ id: "relay", name: "Relay", title: "External agent", harness: "external", connectionId: "", model: "" },
|
||||
];
|
||||
Reference in new issue
Block a user