2026-06-10 00:02:59 +03:30
import { useCallback , useEffect , useMemo , useState } from 'react'
2026-06-10 13:57:10 +03:30
import { KeyRound , Plus , Bot , Sparkles , Wand2 } from 'lucide-react'
2026-06-10 00:02:59 +03:30
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
2026-06-10 18:13:52 +03:30
endpoint : string | null
2026-06-10 00:02:59 +03:30
}
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 [ ]
docs : 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 [ seats , setSeats ] = useState < Seat [ ] > ( [ ] )
const [ skills , setSkills ] = useState < Skill [ ] > ( [ ] )
2026-06-10 18:13:52 +03:30
const [ cfg , setCfg ] = useState ( { name : '' , provider : 'stub' , model : 'gpt-4o-mini' , apiKey : '' , endpoint : '' } )
2026-06-10 00:02:59 +03:30
const [ newSeat , setNewSeat ] = useState ( '' )
const [ selectedSeat , setSelectedSeat ] = useState < string | null > ( null )
const [ agent , setAgent ] = useState ( {
name : '' ,
monogram : '' ,
autonomy : 'Gated' ,
apiConfigId : '' ,
skillKeys : [ ] as string [ ] ,
docs : '' ,
} )
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 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 } ` ) )
2026-06-13 13:35:53 +03:30
// 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 ( ) ] )
2026-06-10 00:02:59 +03:30
await loadConfigs ( )
} )
} , [ organizationId , loadConfigs , run ] )
useEffect ( ( ) = > {
if ( teamId ) void run ( ( ) = > loadSeats ( teamId ) )
} , [ teamId , loadSeats , run ] )
const createConfig = ( ) = >
run ( async ( ) = > {
2026-06-10 18:13:52 +03:30
await api . post ( '/api/integrations/api-configs' , { organizationId , . . . cfg , endpoint : cfg.endpoint.trim ( ) || null } )
setCfg ( { name : '' , provider : 'stub' , model : 'gpt-4o-mini' , apiKey : '' , endpoint : '' } )
2026-06-10 00:02:59 +03:30
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 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 ,
docs : existing.docs.join ( ', ' ) ,
}
: { name : '' , monogram : '' , autonomy : 'Gated' , apiConfigId : configs [ 0 ] ? . id ? ? '' , skillKeys : [ ] , docs : '' } ,
)
} )
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 ,
docs : agent.docs ? agent . docs . split ( ',' ) . map ( ( d ) = > d . trim ( ) ) . filter ( Boolean ) : [ ] ,
} )
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 >
2026-06-10 18:13:52 +03:30
{ [ 'stub' , 'openai' , 'ollama' , 'vllm' , 'custom' ] . map ( ( p ) = > (
2026-06-10 00:02:59 +03:30
< 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 >
2026-06-10 18:13:52 +03:30
< 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 >
2026-06-10 00:02:59 +03:30
< 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 >
2026-06-10 18:13:52 +03:30
< span className = "text-muted-foreground" >
{ c . provider } · { c . model }
{ c . endpoint ? ` · ${ c . endpoint } ` : '' }
< / span >
2026-06-10 00:02:59 +03:30
< 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 = "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" >
< 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 >
2026-06-10 13:57:10 +03:30
{ selected && (
< SuggestedSkills
roleName = { selected . roleName }
skills = { skills }
current = { agent . skillKeys }
onApply = { ( keys ) = > setAgent ( { . . . agent , skillKeys : keys } ) }
/ >
) }
2026-06-10 00:02:59 +03:30
< div className = "flex flex-wrap gap-2" >
{ skills . map ( ( skill ) = > (
2026-06-13 13:35:53 +03:30
< 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 } >
2026-06-10 00:02:59 +03:30
< Badge variant = { agent . skillKeys . includes ( skill . skillKey ) ? 'default' : 'outline' } >
2026-06-13 13:35:53 +03:30
{ skill . name } { skill . status !== 'Published' ? ' · draft' : '' }
2026-06-10 00:02:59 +03:30
< / Badge >
< / button >
) ) }
{ skills . length === 0 && < p className = "text-sm text-muted-foreground" > No skills indexed yet . < / 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 >
< 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 >
)
}
2026-06-10 13:57:10 +03:30
/** 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 >
)
}