diff --git a/DESIGN.md b/DESIGN.md index 0026b138d16..eff137181a8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index fbbdd91a2fc..1164ebdb622 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -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 diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index f9b51ac805c..43b6f31a65f 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -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. diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 0429f0757af..3ae50819a66 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -48,3 +48,11 @@ export function agentHarnessType(adapterType: string, config: Record = {}): string { + const harness = agentHarnessType(adapterType, config); + if (harness === "acpx:copilot") return "copilot_runtime"; + if (harness === "cursor") return "cursor_local"; + return harness; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 613add068bc..2b204b3cb88 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index edbcf18c7f4..80ad7076e6b 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -151,12 +151,28 @@ export const createAgentHireSchema = createAgentSchema.omit({ setupId: true }).e export type CreateAgentHire = z.infer; +// 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; + 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(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9b2f1a83b24..b8fc304b7ce 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -350,6 +350,8 @@ export { builtInAgentResetSchema, createAgentHireSchema, updateAgentSchema, + agentRuntimeSettingsPatchSchema, + type AgentRuntimeSettingsPatch, agentRuntimeConfigSchema, agentInstructionsBundleModeSchema, updateAgentInstructionsBundleSchema, diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index c1349850970..0c445913998 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -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); diff --git a/server/src/__tests__/agent-runtime-settings.test.ts b/server/src/__tests__/agent-runtime-settings.test.ts new file mode 100644 index 00000000000..92603d1b813 --- /dev/null +++ b/server/src/__tests__/agent-runtime-settings.test.ts @@ -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); + }); +}); diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index 01b41aaa9c0..786f61af550 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -74,6 +74,7 @@ export interface ClaudeLoginContext { } export interface UpdateAgentOptions { + expectedConfiguration?: Pick; recordRevision?: RevisionMetadata; allowBuiltInAgentMetadata?: boolean; allowPendingApprovalConfigUpdate?: boolean; diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 77ab1e27696..681a6ec6735 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -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, diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts index d5c40d4b610..65333d4bbbd 100644 --- a/server/src/modules/agent-lifecycle/adapters/configuration.ts +++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts @@ -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 => diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 02c9d65ac3f..a2986d558ea 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) { + async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record, 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) }; + const runtimeSettings = req.body.runtimeSettings; + if (runtimeSettings) await assertCanConfigureAgentSetup(req, existing); + const patchData = runtimeSettings + ? prepareRuntimeSettingsPatch(existing, req.body) + : { ...(req.body as Record) }; 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, diff --git a/server/src/services/agent-runtime-settings.ts b/server/src/services/agent-runtime-settings.ts new file mode 100644 index 00000000000..70e37bd854b --- /dev/null +++ b/server/src/services/agent-runtime-settings.ts @@ -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, input: UpdateAgent): Record { + 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 } } : {}), + }; +} diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0b05d9a4fee..b1d0e32587c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -159,6 +159,8 @@ export const agentsApi = { api.post(`/companies/${companyId}/agents`, data), hire: (companyId: string, data: Record) => api.post(`/companies/${companyId}/agent-hires`, data), + updateRuntimeSettings: (id: string, runtimeSettings: import("@paperclipai/shared").AgentRuntimeSettingsPatch, companyId: string) => + api.patch(agentPath(id, companyId), { runtimeSettings }), update: (id: string, data: Record, companyId?: string) => api.patch(agentPath(id, companyId), data), updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) => diff --git a/ui/src/components/AgentsToolbar.tsx b/ui/src/components/AgentsToolbar.tsx index adf09e3a2cb..a94ae0c6b9e 100644 --- a/ui/src/components/AgentsToolbar.tsx +++ b/ui/src/components/AgentsToolbar.tsx @@ -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
@@ -28,8 +24,5 @@ export function AgentsToolbar({ query, onQueryChange, tab, items, onTabChange, v
-
- -
; } diff --git a/ui/src/components/agents/AgentBulkRoster.tsx b/ui/src/components/agents/AgentBulkRoster.tsx new file mode 100644 index 00000000000..88472ce55bc --- /dev/null +++ b/ui/src/components/agents/AgentBulkRoster.tsx @@ -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([]); + const [filter, setFilter] = useState("all"); + const [editing, setEditing] = useState(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
+
+ + + + + +
+ Filters + {filter !== "all" && } +
+
+
Harness
+ {["all", ...harnesses].map(h => )} +
+
+
+ {visible.length} {visible.length === 1 ? "agent" : "agents"} +
+ {selectedAgents.length > 0 &&
+ {selectedAgents.length} selected + {hidden > 0 && {hidden} hidden by filters} + {groups.length > 1 && Choose a harness to edit} +
{groups.map(h => )}
+
} + {notice &&

{notice}

} + {accounts.error &&

Connection names couldn’t be loaded.

} +
+ + + + + {visible.map(agent => + + + + + + )} +
AgentHarnessConnectionDefault model
a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => setSelected(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} />{renderAgent(agent)}{getAdapterLabel(bulkAgentHarness(agent))}{["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}{agentDefaultModel(agent) ?? "Harness default"}
+ {!visible.length &&

No agents match these filters.

} + {editing && Loading editor…

}> { + 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"}.`); + } + }} />
} +
; +} diff --git a/ui/src/components/agents/AgentBulkSettingsSheet.test.tsx b/ui/src/components/agents/AgentBulkSettingsSheet.test.tsx new file mode 100644 index 00000000000..38348afd650 --- /dev/null +++ b/ui/src/components/agents/AgentBulkSettingsSheet.test.tsx @@ -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( + + )); + await vi.waitFor(() => expect(client.isFetching()).toBe(0)); +} + +function button(name: string) { + const result = [...document.querySelectorAll("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('[aria-label="Change default model"]')!.click()); + await act(async () => button("Model").click()); + if (manual) { + const input = document.querySelector('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('[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('[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"]); + }); +}); diff --git a/ui/src/components/agents/AgentBulkSettingsSheet.tsx b/ui/src/components/agents/AgentBulkSettingsSheet.tsx new file mode 100644 index 00000000000..5e93ecca7a3 --- /dev/null +++ b/ui/src/components/agents/AgentBulkSettingsSheet.tsx @@ -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(); + 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([]); + const [failures, setFailures] = useState(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>(); + 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(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 { if (!open) close(); }}> { if (savingRef.current) event.preventDefault(); }} onInteractOutside={event => { if (savingRef.current) event.preventDefault(); }}> + Update {agents.length} {label} {agents.length === 1 ? "agent" : "agents"}{totalSelected > agents.length ? `${otherSelected} other selected ${otherSelected === 1 ? "agent" : "agents"} won’t change.` : "Change their connection, default model, or both."} +
+
{agents.map(a => {a.name})}
+ {failures &&

{updatedIds.length} updated · {failures.length} couldn’t be saved

{failures.map(f =>

{f.agent.name}: {f.message}

)}

Retry updates only the remaining agents. Close and reopen to review changed settings.

} +
+
+ {changeConnection &&
{ + if (value.mode === "router") { setSelectionError("Configure connection pools on each agent’s configuration page."); return; } + setSelectionError(""); setBinding(value); + }} />
} +
+
+ {changeModel &&

{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"}.`}

} +
+
+ {catalogs.isFetching &&

Checking connections and models…

} + {(catalogs.error || loadErrors.length > 0) &&

{catalogs.error?.message}

{loadErrors.map(r =>

{r.agent.name}: {"error" in r ? r.error : ""}

)}
} + {selectionError &&

{selectionError}

} + {problems.length > 0 &&
{problems.map(p =>

{p.agent.name}: {p.message}

)}{!changeModel && !failures && }
} + {!missing && changed.length > 0 &&

CHANGES TO APPLY

{changed.map(agent =>

{agent.name}

{changeConnection && !sameBinding(binding, agentConnection(agent)) &&

{bulkConnectionName(agentConnection(agent), rows.find(r => r.agent.id === agent.id)?.accounts)}{bulkConnectionName(binding, rows.find(r => r.agent.id === agent.id)?.accounts)}

}{changeModel && model !== agentDefaultModel(agent) &&

{agentDefaultModel(agent) ?? "Harness default"}{model}

}
)}
{targets.length > changed.length &&

{targets.length - changed.length} already have these settings.

}
} + {!missing && !changed.length &&

All selected agents already have these settings.

} +

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.

+
+ +
; +} diff --git a/ui/src/lib/bulk-agent-settings.test.ts b/ui/src/lib/bulk-agent-settings.test.ts new file mode 100644 index 00000000000..2f8645f1894 --- /dev/null +++ b/ui/src/lib/bulk-agent-settings.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/bulk-agent-settings.ts b/ui/src/lib/bulk-agent-settings.ts new file mode 100644 index 00000000000..26488afa79c --- /dev/null +++ b/ui/src/lib/bulk-agent-settings.ts @@ -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(items: T[], fn: (item: T) => Promise, concurrency = 4): Promise[]> { + const results: PromiseSettledResult[] = 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; +} diff --git a/ui/src/pages/Agents.production.tsx b/ui/src/pages/Agents.production.tsx index 8c20e535e5c..6fc5c22d214 100644 --- a/ui/src/pages/Agents.production.tsx +++ b/ui/src/pages/Agents.production.tsx @@ -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 ; @@ -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 ? ( ) : ( )} - secondaryRow={ - builtInCluster ? ( -
- {builtInCluster} -
- ) : undefined - } - meta={ -
- {builtInCluster && ( -
- {builtInCluster} -
- )} -
- -
+ secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")} + secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && ( +
+ {agent.status === "paused" && } + {builtInCluster} + {showEnvironmentColumn && + {resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail} + } + {agent.lastHeartbeatAt && {relativeTime(agent.lastHeartbeatAt)}}
- } - metaSpacerClassName="hidden xl:block" + )} trailing={
@@ -441,14 +405,7 @@ export function Agents() { return (
-
- navigate(`/agents/${v}`)}> - navigate(`/agents/${v}`)} - /> - +
{/* View toggle */} {!forceListView && ( @@ -502,17 +459,13 @@ export function Agents() { )} {/* List view */} - {effectiveView === "list" && filtered.length > 0 && ( -
- {filtered.map(renderAgentRow)} -
+ {effectiveView === "list" && agents && agents.length > 0 && ( + !HIDDEN_AGENT_STATUSES.has(agent.status))} + visibleAgents={filtered} renderAgent={renderAgentRow} /> )} - {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && ( -

- No agents match the selected status. -

- )} + {/* 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; environmentDataLoading: boolean; showEnvironment: boolean; - tab: FilterTab; - memberships: ReturnType["data"]; builtInByAgentId: Map; 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 (
{hasInvalidOrgChain ? ( @@ -689,8 +631,6 @@ function OrgTreeNode({ environmentByAgentId={environmentByAgentId} environmentDataLoading={environmentDataLoading} showEnvironment={showEnvironment} - tab={tab} - memberships={memberships} builtInByAgentId={builtInByAgentId} onConfigureBuiltIn={onConfigureBuiltIn} /> diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index 4d307e5a2c0..f723873a4be 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -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(); }); 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('[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(); }); await flushReact(); await flushReact(); + await act(async () => { container.querySelector('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 () => { diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 1ef594f04ab..5dd7995e6f0 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -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 ; @@ -386,43 +368,28 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = key={agent.id} title={agent.name} titleAccessory={} - 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 ? ( ) : ( )} - secondaryRow={builtInCluster && ( -
+ secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")} + secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && ( +
+ {agent.status === "paused" && } {builtInCluster} + {showEnvironmentColumn && + {resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail} + } + {agent.lastHeartbeatAt && {relativeTime(agent.lastHeartbeatAt)}}
)} - meta={ -
- {builtInCluster && ( -
- {builtInCluster} -
- )} -
- -
-
- } - metaSpacerClassName="hidden @5xl:block" trailing={
{agentChat.enabled && } @@ -445,17 +412,14 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = }; return ( - navigate(`/agents/${value}`)} className={cn( - "@container", - effectiveView === "org" - ? "flex h-full min-h-0 flex-col gap-4" - : "gap-4", +
- navigate(`/agents/${value}`)} view={effectiveView} onViewChange={setView} + - +
{error &&

{error.message}

} {agents && agents.length === 0 && ( @@ -468,17 +432,13 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = )} {/* List view */} - {effectiveView === "list" && filtered.length > 0 && ( -
- {filtered.map(renderAgentRow)} -
+ {effectiveView === "list" && agents && agents.length > 0 && ( + !HIDDEN_AGENT_STATUSES.has(agent.status))} + visibleAgents={filtered} renderAgent={renderAgentRow} /> )} - {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && ( -

- No agents match your search and selected status. -

- )} + {/* Org chart view */} {effectiveView === "org" && filteredOrg.length > 0 && ( @@ -508,8 +468,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = /> )} - - +
+
); } @@ -521,8 +481,6 @@ function OrgTreeNode({ environmentByAgentId, environmentDataLoading, showEnvironment, - tab, - memberships, builtInByAgentId, onConfigureBuiltIn, }: { @@ -533,8 +491,6 @@ function OrgTreeNode({ environmentByAgentId: Map; environmentDataLoading: boolean; showEnvironment: boolean; - tab: FilterTab; - memberships: ReturnType["data"]; builtInByAgentId: Map; 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 (
{hasInvalidOrgChain ? ( @@ -639,8 +590,6 @@ function OrgTreeNode({ environmentByAgentId={environmentByAgentId} environmentDataLoading={environmentDataLoading} showEnvironment={showEnvironment} - tab={tab} - memberships={memberships} builtInByAgentId={builtInByAgentId} onConfigureBuiltIn={onConfigureBuiltIn} /> diff --git a/ui/storybook/stories/agents/bulk-settings.stories.tsx b/ui/storybook/stories/agents/bulk-settings.stories.tsx new file mode 100644 index 00000000000..b1ae65d3042 --- /dev/null +++ b/ui/storybook/stories/agents/bulk-settings.stories.tsx @@ -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 ? }>} /> : 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; +export default meta; +type Story = StoryObj; +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"); +} }; diff --git a/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx new file mode 100644 index 00000000000..f92685f6e65 --- /dev/null +++ b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; +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"); + }, +}; diff --git a/ui/storybook/support/design-proposals/BulkAgentSettingsProposal.tsx b/ui/storybook/support/design-proposals/BulkAgentSettingsProposal.tsx new file mode 100644 index 00000000000..c9d5a158b35 --- /dev/null +++ b/ui/storybook/support/design-proposals/BulkAgentSettingsProposal.tsx @@ -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 External; + const info = harnesses[harness]; + return {info.label}; +} + +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(initialEditor === "incompatible" ? bindingFor("codex-limited") : undefined); + const [model, setModel] = useState(""); + const [modelOpen, setModelOpen] = useState(false); + const [failedIds, setFailedIds] = useState(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 <> + + Update {agents.length} {info.label} agents + {otherCount > 0 ? `${otherCount} other selected agents won’t change.` : "Change their connection, default model, or both."} + +
+
+ {agents.map(agent => {agent.name})} +
+ {failedIds &&
+

{savedCount} updated · {failedIds.length} couldn’t be saved

+

{targets.map(agent => agent.name).join(", ")}: connection interrupted while saving. Retry updates only this agent.

+
} +
+
+ + {changeConnection &&
+ setAccountNotice(true)} /> + {accountNotice &&

Account setup is outside this preview. Choose one of the sample connections.

} +
} +
+
+ + {changeModel &&
({ id, label: id }))} value={model} onChange={setModel} + open={modelOpen} onOpenChange={setModelOpen} allowDefault={false} required groupByProvider={false} preserveOrder /> +

Available on every selected agent’s {changeConnection ? "new connection" : "current connection"}.

+
} +
+
+ {!missing && invalid.length > 0 &&
+

{invalid.map(agent => agent.name).join(", ")} needs a compatible model

+

{changeModel ? model : invalid[0].model} isn’t available on the selected connection. Choose a different connection or change the default model.

+ {!changeModel && } +
} + {!missing && changed.length > 0 &&
+

CHANGES TO APPLY

+
{changed.map(agent =>
+

{agent.name}

+ {changeConnection && targetConnection !== agent.connectionId &&

{connectionName(agent.connectionId)}{connectionName(targetConnection!)}

} + {changeModel && model !== agent.model &&

{agent.model}{model}

} +
)}
+ {targets.length > changed.length &&

{targets.length - changed.length} already {targets.length - changed.length === 1 ? "has" : "have"} these settings.

} +
} + {!missing && changed.length === 0 && (changeConnection || changeModel) &&

All selected agents already have these settings.

} +

Applies to future runs. Running work continues with its current settings. Task-specific model overrides stay in place.

+
+ + + + + ; +} + +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(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(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
+ setNotice("This preview focuses on bulk agent settings.")} />} + headerActions={Interactive proposal · sample data}> + + {}} hideViewToggle onNewAgent={() => setNotice("Agent creation is outside this preview.")} /> + + +
+ + {visible.length} agents +
+ {selected.length > 0 &&
+ {selected.length} selected + {hiddenCount > 0 && {hiddenCount} hidden by filters} + {groups.length > 1 && Choose a harness to edit} +
{groups.map(harness => )}
+
} + {notice &&

{notice}

} +
+ + {visible.map(agent => + + + + + + )} +
AgentHarnessConnectionDefault model
setSelected(value ? [...selected, agent.id] : selected.filter(id => id !== agent.id))} />
{agent.name}

{agent.title}{agent.paused ? " · Paused" : ""}

{agent.connectionId ? connectionName(agent.connectionId) : "Managed externally"}{agent.model || "—"}
+ {!visible.length &&

No agents match these filters.

} + {!selected.length &&

Select agents to update their connection or default model.

} +
+
+
+ { if (!open && !saving) close(); }}> + {editing && 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} />} + +
; +} + +export function BulkAgentSettingsProposal(props: BulkAgentSettingsProposalProps) { + return ; +} diff --git a/ui/storybook/support/design-proposals/bulk-agent-settings-data.ts b/ui/storybook/support/design-proposals/bulk-agent-settings-data.ts new file mode 100644 index 00000000000..5d480131ca9 --- /dev/null +++ b/ui/storybook/support/design-proposals/bulk-agent-settings-data.ts @@ -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: "" }, +];