Files
Teamup/client/src/pages/SeatsPage.tsx
T
soroush.asadi f79dbda8d2 Apply an agent profile to a seat: prefill identity, autonomy, skills, and persona
The AI-seats configurator gains a "Start from a profile (AGENTS.md)" picker. Selecting one loads
the org's resolved profile (builtins + authored + installed, one per key) and prefills the agent's
name, monogram, recommended autonomy, and skills (intersected with the org's skill library), and sets
the operating-guide persona — all still editable before saving. A persona textarea is shown and sent
to ConfigureAgent (already persisted + injected into the run as "# Operating guide"). Closes the loop:
upload/install an AGENTS.md → stand up a seat from it in one step.

Frontend only; the persona/ConfigureAgent path is covered by existing tests. Client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 04:14:23 +03:30

636 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { KeyRound, Plug, Plus, Bot, Sparkles, Trash2, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Team {
id: string
name: string
}
interface ApiConfig {
id: string
name: string
provider: string
model: string
endpoint: string | null
}
interface McpServer {
id: string
name: string
endpoint: string
enabled: boolean
headerNames: string[]
}
interface Seat {
id: string
teamId: string
roleName: string
state: string
agentId?: string | null
}
interface Skill {
skillKey: string
name: string
roles: string[]
status: string
}
interface Agent {
id: string
name: string
monogram?: string | null
autonomy: string
apiConfigId: string
skillKeys: string[]
mcpServerIds: string[]
docs: string[]
persona?: string | null
}
interface AgentProfileLite {
id: string
profileKey: string
name: string
monogram?: string | null
recommendedAutonomy: string
skillKeys: string[]
}
interface AgentProfileDetail {
profile: AgentProfileLite
body: string
}
const AUTONOMY = [
{ value: 'DraftOnly', label: 'Draft', on: 'bg-slate-600 text-white' },
{ value: 'Gated', label: 'Gated', on: 'bg-indigo-600 text-white' },
{ value: 'Autonomous', label: 'Auto', on: 'bg-teal-600 text-white' },
] as const
export function SeatsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [teams, setTeams] = useState<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null)
const [configs, setConfigs] = useState<ApiConfig[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [seats, setSeats] = useState<Seat[]>([])
const [skills, setSkills] = useState<Skill[]>([])
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
const [mcp, setMcp] = useState({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
const [newSeat, setNewSeat] = useState('')
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
const [profiles, setProfiles] = useState<AgentProfileLite[]>([])
const [agent, setAgent] = useState({
name: '',
monogram: '',
autonomy: 'Gated',
apiConfigId: '',
skillKeys: [] as string[],
mcpServerIds: [] as string[],
docs: '',
persona: '',
})
const run = useCallback(async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
toast.error((err as Error).message)
}
}, [])
const loadConfigs = useCallback(async () => {
if (!organizationId) return
setConfigs(await api.get<ApiConfig[]>(`/api/integrations/api-configs?organizationId=${organizationId}`))
}, [organizationId])
const loadMcpServers = useCallback(async () => {
if (!organizationId) return
setMcpServers(await api.get<McpServer[]>(`/api/integrations/mcp-servers?organizationId=${organizationId}`))
}, [organizationId])
const loadSeats = useCallback(async (id: string) => {
setSeats(await api.get<Seat[]>(`/api/orgboard/seats?teamId=${id}`))
}, [])
useEffect(() => {
if (!organizationId) return
void run(async () => {
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
// The org's library = shared builtins + its own authored/installed skills. The API returns
// every version (newest first per key); collapse to one selectable entry per key.
const lib = await api.get<Skill[]>(`/api/skills/?organizationId=${organizationId}`)
const byKey = new Map<string, Skill>()
for (const s of lib) if (!byKey.has(s.skillKey)) byKey.set(s.skillKey, s)
setSkills([...byKey.values()])
// Agent profiles (AGENTS.md): one selectable entry per key (the resolvable winner is first).
const profileList = await api.get<AgentProfileLite[]>(`/api/orgboard/agent-profiles?organizationId=${organizationId}`)
const byProfileKey = new Map<string, AgentProfileLite>()
for (const p of profileList) if (!byProfileKey.has(p.profileKey)) byProfileKey.set(p.profileKey, p)
setProfiles([...byProfileKey.values()])
await loadConfigs()
await loadMcpServers()
})
}, [organizationId, loadConfigs, loadMcpServers, run])
useEffect(() => {
if (teamId) void run(() => loadSeats(teamId))
}, [teamId, loadSeats, run])
const createConfig = () =>
run(async () => {
await api.post('/api/integrations/api-configs', { organizationId, ...cfg, endpoint: cfg.endpoint.trim() || null })
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
await loadConfigs()
toast.success('API config saved (key encrypted).')
})
const testConfig = (id: string) =>
run(async () => {
const result = await api.post<{ success: boolean; error?: string; latencyMs: number }>(
`/api/integrations/api-configs/${id}/test`,
)
result.success
? toast.success(`Test call succeeded (${result.latencyMs} ms).`)
: toast.error(`Test failed: ${result.error}`)
})
const createMcpServer = () =>
run(async () => {
const headers = mcp.headerValue.trim() && mcp.headerName.trim()
? { [mcp.headerName.trim()]: mcp.headerValue.trim() }
: null
await api.post('/api/integrations/mcp-servers', {
organizationId,
name: mcp.name.trim(),
endpoint: mcp.endpoint.trim(),
headers,
})
setMcp({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
await loadMcpServers()
toast.success('MCP server added (auth header encrypted).')
})
const testMcpServer = (id: string) =>
run(async () => {
const result = await api.post<{ success: boolean; error?: string; toolCount: number; toolNames: string[] }>(
`/api/integrations/mcp-servers/${id}/test`,
)
result.success
? toast.success(`Connected — ${result.toolCount} tool(s): ${result.toolNames.slice(0, 6).join(', ') || 'none'}.`)
: toast.error(`MCP test failed: ${result.error}`)
})
const deleteMcpServer = (id: string) =>
run(async () => {
await api.del(`/api/integrations/mcp-servers/${id}`)
await loadMcpServers()
})
const toggleMcp = (id: string) =>
setAgent((a) => ({
...a,
mcpServerIds: a.mcpServerIds.includes(id) ? a.mcpServerIds.filter((x) => x !== id) : [...a.mcpServerIds, id],
}))
const createSeat = () =>
run(async () => {
if (!teamId) return
await api.post('/api/orgboard/seats', { teamId, roleName: newSeat })
setNewSeat('')
await loadSeats(teamId)
})
const selectSeat = (seat: Seat) =>
run(async () => {
setSelectedSeat(seat.id)
const existing = seat.agentId
? await api.get<Agent>(`/api/orgboard/seats/${seat.id}/agent`).catch(() => null)
: null
setAgent(
existing
? {
name: existing.name,
monogram: existing.monogram ?? '',
autonomy: existing.autonomy,
apiConfigId: existing.apiConfigId,
skillKeys: existing.skillKeys,
mcpServerIds: existing.mcpServerIds ?? [],
docs: existing.docs.join(', '),
persona: existing.persona ?? '',
}
: { name: '', monogram: '', autonomy: 'Gated', apiConfigId: configs[0]?.id ?? '', skillKeys: [], mcpServerIds: [], docs: '', persona: '' },
)
})
// Apply an AGENTS.md profile to the seat: prefill identity, autonomy, skills, and the persona
// (operating guide). The user can still tweak everything before saving.
const applyProfile = (key: string) =>
run(async () => {
const versions = await api.get<AgentProfileDetail[]>(`/api/orgboard/agent-profiles/${key}?organizationId=${organizationId}`)
const chosen = versions[0]
if (!chosen) return
const known = new Set(skills.map((s) => s.skillKey))
setAgent((a) => ({
...a,
name: chosen.profile.name,
monogram: chosen.profile.monogram ?? '',
autonomy: chosen.profile.recommendedAutonomy,
skillKeys: chosen.profile.skillKeys.filter((k) => known.has(k)),
persona: chosen.body,
}))
toast.success(`Applied “${chosen.profile.name}”. Review and save.`)
})
const saveAgent = () =>
run(async () => {
if (!selectedSeat) return
await api.post(`/api/orgboard/seats/${selectedSeat}/agent`, {
name: agent.name,
monogram: agent.monogram || null,
autonomy: agent.autonomy,
apiConfigId: agent.apiConfigId,
skillKeys: agent.skillKeys,
mcpServerIds: agent.mcpServerIds,
docs: agent.docs ? agent.docs.split(',').map((d) => d.trim()).filter(Boolean) : [],
persona: agent.persona.trim() || null,
})
if (teamId) await loadSeats(teamId)
toast.success(`${agent.name || 'Agent'} configured — seat is now AI.`)
})
const toggleSkill = (key: string) =>
setAgent((a) => ({
...a,
skillKeys: a.skillKeys.includes(key) ? a.skillKeys.filter((k) => k !== key) : [...a.skillKeys, key],
}))
const selected = useMemo(() => seats.find((s) => s.id === selectedSeat) ?? null, [seats, selectedSeat])
return (
<AppShell>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">AI seats</h1>
<p className="text-sm text-muted-foreground">Connect a model (BYOK) and staff a seat with an AI agent.</p>
</header>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-4" /> Model connections (BYOK)
</CardTitle>
<CardDescription>Keys are encrypted server-side and never shown again after saving.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={cfg.name} onChange={(e) => setCfg({ ...cfg, name: e.target.value })} className="w-40" placeholder="Vertex-Pro" />
</Field>
<Field label="Provider">
<Select value={cfg.provider} onValueChange={(v) => setCfg({ ...cfg, provider: v })}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{['stub', 'openai', 'ollama', 'vllm', 'custom'].map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field label="Model">
<Input value={cfg.model} onChange={(e) => setCfg({ ...cfg, model: e.target.value })} className="w-40" />
</Field>
<Field label="API key">
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
</Field>
<Field label="Base URL (OpenAI-compatible; optional)">
<Input
value={cfg.endpoint}
onChange={(e) => setCfg({ ...cfg, endpoint: e.target.value })}
className="w-72"
placeholder="https://my-gateway.example.com"
/>
</Field>
<Button onClick={createConfig}><Plus data-icon="inline-start" />Add</Button>
</div>
<div className="flex flex-col gap-2">
{configs.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{c.name}</span>
<span className="text-muted-foreground">
{c.provider} · {c.model}
{c.endpoint ? ` · ${c.endpoint}` : ''}
</span>
<Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
</div>
))}
{configs.length === 0 && <p className="text-sm text-muted-foreground">No model connections yet.</p>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plug className="size-4" /> MCP servers
</CardTitle>
<CardDescription>
Connect Model Context Protocol servers (Streamable HTTP). Auth headers are encrypted and never
shown again. Bind servers to an agent below their tools are offered to the agent at run time.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={mcp.name} onChange={(e) => setMcp({ ...mcp, name: e.target.value })} className="w-40" placeholder="GitHub MCP" />
</Field>
<Field label="Endpoint URL">
<Input value={mcp.endpoint} onChange={(e) => setMcp({ ...mcp, endpoint: e.target.value })} className="w-72" placeholder="https://host/mcp" />
</Field>
<Field label="Auth header (optional)">
<Input value={mcp.headerName} onChange={(e) => setMcp({ ...mcp, headerName: e.target.value })} className="w-36" placeholder="Authorization" />
</Field>
<Field label="Header value (optional)">
<Input type="password" value={mcp.headerValue} onChange={(e) => setMcp({ ...mcp, headerValue: e.target.value })} className="w-48" placeholder="Bearer …" />
</Field>
<Button onClick={createMcpServer}><Plus data-icon="inline-start" />Add</Button>
</div>
<div className="flex flex-col gap-2">
{mcpServers.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{s.name}</span>
<span className="truncate text-muted-foreground">
{s.endpoint}{s.headerNames.length > 0 ? ` · auth: ${s.headerNames.join(', ')}` : ''}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => testMcpServer(s.id)}>Test</Button>
<Button variant="ghost" size="sm" onClick={() => deleteMcpServer(s.id)}><Trash2 className="size-4" /></Button>
</div>
</div>
))}
{mcpServers.length === 0 && <p className="text-sm text-muted-foreground">No MCP servers yet.</p>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Team</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<Field label="Team">
<Select value={teamId ?? ''} onValueChange={(v) => setTeamId(v || null)}>
<SelectTrigger className="w-56"><SelectValue placeholder="Select a team" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{teams.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{teamId && (
<Field label="New seat (role)">
<div className="flex gap-2">
<Input value={newSeat} onChange={(e) => setNewSeat(e.target.value)} className="w-48" placeholder="Product Owner" />
<Button onClick={createSeat}><Plus data-icon="inline-start" />Create</Button>
</div>
</Field>
)}
</CardContent>
</Card>
{teamId && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Seats</CardTitle>
<CardDescription>Pick a seat to configure its agent.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{seats.map((seat) => (
<button
key={seat.id}
onClick={() => selectSeat(seat)}
className={cn(
'flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm',
selectedSeat === seat.id && 'border-indigo-500 ring-1 ring-indigo-500',
)}
>
<span className="font-medium">{seat.roleName}</span>
<Badge variant={seat.state === 'Ai' ? 'default' : 'secondary'}>{seat.state}</Badge>
</button>
))}
{seats.length === 0 && <p className="text-sm text-muted-foreground">No seats yet.</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="size-4" /> Agent
</CardTitle>
<CardDescription>
{selected ? `Configure “${selected.roleName}` : 'Select a seat on the left.'}
</CardDescription>
</CardHeader>
{selected && (
<CardContent className="flex flex-col gap-4">
{profiles.length > 0 && (
<Field label="Start from a profile (AGENTS.md)">
<Select value="" onValueChange={applyProfile}>
<SelectTrigger className="w-72"><SelectValue placeholder="Apply an agent profile…" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{profiles.map((p) => <SelectItem key={p.profileKey} value={p.profileKey}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)}
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={agent.name} onChange={(e) => setAgent({ ...agent, name: e.target.value })} className="w-40" placeholder="Aria" />
</Field>
<Field label="Monogram">
<Input value={agent.monogram} onChange={(e) => setAgent({ ...agent, monogram: e.target.value })} className="w-20" placeholder="AR" />
</Field>
</div>
<div className="flex flex-col gap-2">
<Label>Autonomy</Label>
<div className="flex gap-2">
{AUTONOMY.map((a) => (
<button
key={a.value}
onClick={() => setAgent({ ...agent, autonomy: a.value })}
className={cn(
'rounded-md border px-3 py-1.5 text-sm',
agent.autonomy === a.value ? a.on : 'text-muted-foreground',
)}
>
{a.label}
</button>
))}
</div>
</div>
<Field label="Model connection">
<Select value={agent.apiConfigId} onValueChange={(v) => setAgent({ ...agent, apiConfigId: v })}>
<SelectTrigger className="w-64"><SelectValue placeholder="Pick a connection" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{configs.map((c) => <SelectItem key={c.id} value={c.id}>{c.name} · {c.model}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<div className="flex flex-col gap-2">
<Label>Skills</Label>
{selected && (
<SuggestedSkills
roleName={selected.roleName}
skills={skills}
current={agent.skillKeys}
onApply={(keys) => setAgent({ ...agent, skillKeys: keys })}
/>
)}
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)} title={skill.status !== 'Published' ? 'Draft — add roles + a golden test to publish before an agent can run it' : undefined}>
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
{skill.name}{skill.status !== 'Published' ? ' · draft' : ''}
</Badge>
</button>
))}
{skills.length === 0 && <p className="text-sm text-muted-foreground">No skills indexed yet.</p>}
</div>
</div>
<div className="flex flex-col gap-2">
<Label>MCP servers</Label>
<div className="flex flex-wrap gap-2">
{mcpServers.map((s) => (
<button key={s.id} onClick={() => toggleMcp(s.id)} title={s.endpoint}>
<Badge variant={agent.mcpServerIds.includes(s.id) ? 'default' : 'outline'}>
<Plug className="mr-1 size-3" />{s.name}
</Badge>
</button>
))}
{mcpServers.length === 0 && <p className="text-sm text-muted-foreground">No MCP servers connected add one above.</p>}
</div>
</div>
<Field label="Docs (comma-separated)">
<Input value={agent.docs} onChange={(e) => setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" />
</Field>
<div className="flex flex-col gap-2">
<Label>Operating guide (persona)</Label>
<textarea
value={agent.persona}
onChange={(e) => setAgent({ ...agent, persona: e.target.value })}
rows={4}
placeholder="The agent's persona / operating guide — set by a profile, editable here. Injected into the run."
className="w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
<Button onClick={saveAgent} className="self-start">
<Wand2 data-icon="inline-start" />
Save agent
</Button>
</CardContent>
)}
</Card>
</div>
)}
</div>
</AppShell>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<Label>{label}</Label>
{children}
</div>
)
}
/** Maps a free-text seat role name to skill role tags — any role can be AI-staffed. */
function roleTagsFor(roleName: string): string[] {
const n = roleName.toLowerCase()
const tags: string[] = []
if (n.includes('product') || n.includes('owner') || n.includes('pm')) tags.push('product-owner')
if (n.includes('qa') || n.includes('test') || n.includes('quality')) tags.push('qa')
if (n.includes('engineer') || n.includes('dev') || n.includes('programmer') || n.includes('backend') || n.includes('frontend')) tags.push('engineer')
if (n.includes('design') || n.includes('ux') || n.includes('ui')) tags.push('designer')
if (n.includes('analyst') || n.includes('analysis') || n.includes('business')) tags.push('analyst')
return tags
}
/** Suggests the skill set matching the seat's role — one click staffs any role with AI. */
function SuggestedSkills({
roleName,
skills,
current,
onApply,
}: {
roleName: string
skills: { skillKey: string; name: string; roles: string[] }[]
current: string[]
onApply: (keys: string[]) => void
}) {
const tags = roleTagsFor(roleName)
const suggested = skills.filter((s) => s.roles.some((r) => tags.includes(r)))
if (suggested.length === 0) return null
const keys = suggested.map((s) => s.skillKey)
const applied = keys.every((k) => current.includes(k))
return (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="min-w-0 truncate">
Suggested for {roleName}: {suggested.map((s) => s.name).join(', ')}
</span>
<Button
variant="outline"
size="sm"
className="ml-auto shrink-0"
disabled={applied}
onClick={() => onApply([...new Set([...current, ...keys])])}
>
{applied ? 'Applied' : 'Use set'}
</Button>
</div>
)
}