feat: add adapter picker for imported agents
When importing a company, users can now choose the adapter type for each imported agent. Defaults to the current company CEO's adapter type (or claude_local if none). Includes an expandable "configure adapter" section per agent that renders the adapter-specific config fields. - Added adapterOverrides to import request schema and types - Built AdapterPickerList UI component in CompanyImport.tsx - Backend applies adapter overrides when creating/updating agents Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -185,6 +185,7 @@ export type {
|
||||
CompanyPortabilityPreviewProjectPlan,
|
||||
CompanyPortabilityPreviewIssuePlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityAdapterOverride,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
|
||||
@@ -191,7 +191,14 @@ export interface CompanyPortabilityPreviewResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {}
|
||||
export interface CompanyPortabilityAdapterOverride {
|
||||
adapterType: string;
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {
|
||||
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityImportResult {
|
||||
company: {
|
||||
|
||||
@@ -109,6 +109,7 @@ export type {
|
||||
CompanyPortabilityPreviewProjectPlan,
|
||||
CompanyPortabilityPreviewIssuePlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityAdapterOverride,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
|
||||
@@ -169,6 +169,13 @@ export const companyPortabilityPreviewSchema = z.object({
|
||||
|
||||
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
||||
|
||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema;
|
||||
export const portabilityAdapterOverrideSchema = z.object({
|
||||
adapterType: z.string().min(1),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
|
||||
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
|
||||
});
|
||||
|
||||
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
|
||||
|
||||
@@ -2260,16 +2260,21 @@ export function companyPortabilityService(db: Db) {
|
||||
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`);
|
||||
}
|
||||
const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" };
|
||||
const adapterConfig = {
|
||||
...manifestAgent.adapterConfig,
|
||||
promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "",
|
||||
} as Record<string, unknown>;
|
||||
const promptTemplate = markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
||||
|
||||
// Apply adapter overrides from request if present
|
||||
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
|
||||
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
|
||||
const baseAdapterConfig = adapterOverride?.adapterConfig
|
||||
? { ...adapterOverride.adapterConfig, promptTemplate }
|
||||
: { ...manifestAgent.adapterConfig, promptTemplate } as Record<string, unknown>;
|
||||
|
||||
const desiredSkills = manifestAgent.skills ?? [];
|
||||
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
||||
adapterConfig,
|
||||
baseAdapterConfig,
|
||||
desiredSkills,
|
||||
);
|
||||
delete adapterConfig.instructionsFilePath;
|
||||
delete baseAdapterConfig.instructionsFilePath;
|
||||
const patch = {
|
||||
name: planAgent.plannedName,
|
||||
role: manifestAgent.role,
|
||||
@@ -2277,7 +2282,7 @@ export function companyPortabilityService(db: Db) {
|
||||
icon: manifestAgent.icon,
|
||||
capabilities: manifestAgent.capabilities,
|
||||
reportsTo: null,
|
||||
adapterType: manifestAgent.adapterType,
|
||||
adapterType: effectiveAdapterType,
|
||||
adapterConfig: adapterConfigWithSkills,
|
||||
runtimeConfig: manifestAgent.runtimeConfig,
|
||||
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilitySource,
|
||||
CompanyPortabilityAdapterOverride,
|
||||
} from "@paperclipai/shared";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,12 +18,16 @@ import { cn } from "../lib/utils";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Github,
|
||||
Package,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Field } from "../components/agent-config-primitives";
|
||||
import { Field, adapterLabels } from "../components/agent-config-primitives";
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
type FileTreeNode,
|
||||
type FrontmatterData,
|
||||
@@ -434,6 +440,120 @@ function ConflictResolutionList({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Adapter type options for import ───────────────────────────────────
|
||||
|
||||
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "claude_local", label: adapterLabels.claude_local ?? "Claude (local)" },
|
||||
{ value: "codex_local", label: adapterLabels.codex_local ?? "Codex (local)" },
|
||||
{ value: "opencode_local", label: adapterLabels.opencode_local ?? "OpenCode (local)" },
|
||||
{ value: "cursor", label: adapterLabels.cursor ?? "Cursor (local)" },
|
||||
];
|
||||
|
||||
// ── Adapter picker for imported agents ───────────────────────────────
|
||||
|
||||
interface AdapterPickerItem {
|
||||
slug: string;
|
||||
name: string;
|
||||
adapterType: string;
|
||||
}
|
||||
|
||||
function AdapterPickerList({
|
||||
agents,
|
||||
adapterOverrides,
|
||||
expandedSlugs,
|
||||
configValues,
|
||||
onChangeAdapter,
|
||||
onToggleExpand,
|
||||
onChangeConfig,
|
||||
}: {
|
||||
agents: AdapterPickerItem[];
|
||||
adapterOverrides: Record<string, string>;
|
||||
expandedSlugs: Set<string>;
|
||||
configValues: Record<string, CreateConfigValues>;
|
||||
onChangeAdapter: (slug: string, adapterType: string) => void;
|
||||
onToggleExpand: (slug: string) => void;
|
||||
onChangeConfig: (slug: string, patch: Partial<CreateConfigValues>) => void;
|
||||
}) {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-5 mt-3">
|
||||
<div className="rounded-md border border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<h3 className="text-sm font-medium">Adapters</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{agents.map((agent) => {
|
||||
const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
|
||||
const isExpanded = expandedSlugs.has(agent.slug);
|
||||
const uiAdapter = getUIAdapter(selectedType);
|
||||
const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType };
|
||||
|
||||
return (
|
||||
<div key={agent.slug}>
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<span className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-wide",
|
||||
"text-blue-500 border-blue-500/30",
|
||||
)}>
|
||||
agent
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{agent.name}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<select
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs outline-none focus:border-foreground"
|
||||
value={selectedType}
|
||||
onChange={(e) => onChangeAdapter(agent.slug, e.target.value)}
|
||||
>
|
||||
{IMPORT_ADAPTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-auto shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors inline-flex items-center gap-1.5",
|
||||
isExpanded
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => onToggleExpand(agent.slug)}
|
||||
>
|
||||
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
|
||||
configure adapter
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-accent/10 px-4 py-3 space-y-3">
|
||||
<uiAdapter.ConfigFields
|
||||
mode="create"
|
||||
isCreate
|
||||
adapterType={selectedType}
|
||||
values={vals}
|
||||
set={(patch) => onChangeConfig(agent.slug, patch)}
|
||||
config={{}}
|
||||
eff={() => "" as any}
|
||||
mark={() => {}}
|
||||
models={[]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function readLocalPackageZip(file: File): Promise<{
|
||||
@@ -493,6 +613,23 @@ export function CompanyImport() {
|
||||
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
||||
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
||||
|
||||
// Adapter override state
|
||||
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
||||
const [adapterExpandedSlugs, setAdapterExpandedSlugs] = useState<Set<string>>(new Set());
|
||||
const [adapterConfigValues, setAdapterConfigValues] = useState<Record<string, CreateConfigValues>>({});
|
||||
|
||||
// Fetch current company agents to find CEO adapter type
|
||||
const { data: companyAgents } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"],
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const ceoAdapterType = useMemo(() => {
|
||||
if (!companyAgents) return "claude_local";
|
||||
const ceo = companyAgents.find((a) => a.role === "ceo");
|
||||
return ceo?.adapterType ?? "claude_local";
|
||||
}, [companyAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Org Chart", href: "/org" },
|
||||
@@ -548,6 +685,15 @@ export function CompanyImport() {
|
||||
setSkippedSlugs(new Set());
|
||||
setConfirmedSlugs(new Set());
|
||||
|
||||
// Initialize adapter overrides — default all agents to the CEO's adapter type
|
||||
const defaultAdapters: Record<string, string> = {};
|
||||
for (const agent of result.manifest.agents) {
|
||||
defaultAdapters[agent.slug] = ceoAdapterType;
|
||||
}
|
||||
setAdapterOverrides(defaultAdapters);
|
||||
setAdapterExpandedSlugs(new Set());
|
||||
setAdapterConfigValues({});
|
||||
|
||||
// Check all files by default, then uncheck COMPANY.md for existing company
|
||||
const allFiles = new Set(Object.keys(result.files));
|
||||
if (targetMode === "existing" && result.manifest.company && result.plan.companyAction === "update") {
|
||||
@@ -620,6 +766,7 @@ export function CompanyImport() {
|
||||
collisionStrategy: "rename",
|
||||
nameOverrides: buildFinalNameOverrides(),
|
||||
selectedFiles: buildSelectedFiles(),
|
||||
adapterOverrides: buildFinalAdapterOverrides(),
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
@@ -786,6 +933,59 @@ export function CompanyImport() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdapterChange(slug: string, adapterType: string) {
|
||||
setAdapterOverrides((prev) => ({ ...prev, [slug]: adapterType }));
|
||||
// Reset config values when adapter type changes
|
||||
setAdapterConfigValues((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[slug];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdapterToggleExpand(slug: string) {
|
||||
setAdapterExpandedSlugs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(slug)) next.delete(slug);
|
||||
else next.add(slug);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdapterConfigChange(slug: string, patch: Partial<CreateConfigValues>) {
|
||||
setAdapterConfigValues((prev) => ({
|
||||
...prev,
|
||||
[slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: adapterOverrides[slug] ?? "claude_local" }), ...patch },
|
||||
}));
|
||||
}
|
||||
|
||||
// Build the list of agents for adapter picking
|
||||
const adapterAgents = useMemo<AdapterPickerItem[]>(() => {
|
||||
if (!importPreview) return [];
|
||||
return importPreview.manifest.agents.map((a) => ({
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
adapterType: a.adapterType,
|
||||
}));
|
||||
}, [importPreview]);
|
||||
|
||||
// Build final adapterOverrides for import request
|
||||
function buildFinalAdapterOverrides(): Record<string, CompanyPortabilityAdapterOverride> | undefined {
|
||||
if (adapterAgents.length === 0) return undefined;
|
||||
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
|
||||
for (const agent of adapterAgents) {
|
||||
const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
|
||||
const configVals = adapterConfigValues[agent.slug];
|
||||
const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType };
|
||||
if (configVals) {
|
||||
const uiAdapter = getUIAdapter(selectedType);
|
||||
override.adapterConfig = uiAdapter.buildAdapterConfig(configVals);
|
||||
}
|
||||
overrides[agent.slug] = override;
|
||||
}
|
||||
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||
}
|
||||
|
||||
const hasSource =
|
||||
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
||||
const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
|
||||
@@ -967,6 +1167,17 @@ export function CompanyImport() {
|
||||
onToggleConfirm={handleConflictToggleConfirm}
|
||||
/>
|
||||
|
||||
{/* Adapter picker list */}
|
||||
<AdapterPickerList
|
||||
agents={adapterAgents}
|
||||
adapterOverrides={adapterOverrides}
|
||||
expandedSlugs={adapterExpandedSlugs}
|
||||
configValues={adapterConfigValues}
|
||||
onChangeAdapter={handleAdapterChange}
|
||||
onToggleExpand={handleAdapterToggleExpand}
|
||||
onChangeConfig={handleAdapterConfigChange}
|
||||
/>
|
||||
|
||||
{/* Import button — below renames */}
|
||||
<div className="mx-5 mt-3 flex justify-end">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user