diff --git a/client/src/App.tsx b/client/src/App.tsx index 3c1d806..0f26b6b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router' import { Toaster } from '@/components/ui/sonner' import { BoardPage } from '@/pages/BoardPage' import { LoginPage } from '@/pages/LoginPage' +import { SeatsPage } from '@/pages/SeatsPage' import { useAuth } from '@/store/auth' export default function App() { @@ -12,6 +13,7 @@ export default function App() { : } /> : } /> + : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 63b9042..d50714c 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' -import { Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react' +import { Link, useLocation } from 'react-router' +import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' @@ -25,8 +26,9 @@ export function AppShell({ children }: { children: ReactNode }) { @@ -54,25 +56,38 @@ export function AppShell({ children }: { children: ReactNode }) { function NavItem({ icon: Icon, label, - active, + to, muted, }: { icon: LucideIcon label: string - active?: boolean + to?: string muted?: boolean }) { - return ( - + const location = useLocation() + const active = to ? location.pathname === to : false + + const className = cn( + 'flex items-center gap-3 rounded-md px-3 py-2 text-sm', + active ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' : 'text-sidebar-foreground/80', + muted ? 'opacity-50' : 'hover:bg-sidebar-accent/60', + ) + + const content = ( + <> {label} {muted && soon} - + + ) + + if (!to || muted) { + return {content} + } + + return ( + + {content} + ) } diff --git a/client/src/pages/SeatsPage.tsx b/client/src/pages/SeatsPage.tsx new file mode 100644 index 0000000..0fe6dda --- /dev/null +++ b/client/src/pages/SeatsPage.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { KeyRound, Plus, Bot, 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 +} + +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([]) + const [teamId, setTeamId] = useState(null) + const [configs, setConfigs] = useState([]) + const [seats, setSeats] = useState([]) + const [skills, setSkills] = useState([]) + + const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + const [newSeat, setNewSeat] = useState('') + const [selectedSeat, setSelectedSeat] = useState(null) + const [agent, setAgent] = useState({ + name: '', + monogram: '', + autonomy: 'Gated', + apiConfigId: '', + skillKeys: [] as string[], + docs: '', + }) + + const run = useCallback(async (action: () => Promise) => { + try { + await action() + } catch (err) { + toast.error((err as Error).message) + } + }, []) + + const loadConfigs = useCallback(async () => { + if (!organizationId) return + setConfigs(await api.get(`/api/integrations/api-configs?organizationId=${organizationId}`)) + }, [organizationId]) + + const loadSeats = useCallback(async (id: string) => { + setSeats(await api.get(`/api/orgboard/seats?teamId=${id}`)) + }, []) + + useEffect(() => { + if (!organizationId) return + void run(async () => { + setTeams(await api.get(`/api/orgboard/teams?organizationId=${organizationId}`)) + setSkills(await api.get('/api/skills/')) + await loadConfigs() + }) + }, [organizationId, loadConfigs, run]) + + useEffect(() => { + if (teamId) void run(() => loadSeats(teamId)) + }, [teamId, loadSeats, run]) + + const createConfig = () => + run(async () => { + await api.post('/api/integrations/api-configs', { organizationId, ...cfg }) + setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + 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(`/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 ( + +
+
+

AI seats

+

Connect a model (BYOK) and staff a seat with an AI agent.

+
+ + + + + Model connections (BYOK) + + Keys are encrypted server-side and never shown again after saving. + + +
+ + setCfg({ ...cfg, name: e.target.value })} className="w-40" placeholder="Vertex-Pro" /> + + + + + + setCfg({ ...cfg, model: e.target.value })} className="w-40" /> + + + setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" /> + + +
+
+ {configs.map((c) => ( +
+ {c.name} + {c.provider} · {c.model} + +
+ ))} + {configs.length === 0 &&

No model connections yet.

} +
+
+
+ + + + Team + + + + + + {teamId && ( + +
+ setNewSeat(e.target.value)} className="w-48" placeholder="Product Owner" /> + +
+
+ )} +
+
+ + {teamId && ( +
+ + + Seats + Pick a seat to configure its agent. + + + {seats.map((seat) => ( + + ))} + {seats.length === 0 &&

No seats yet.

} +
+
+ + + + + Agent + + + {selected ? `Configure “${selected.roleName}”` : 'Select a seat on the left.'} + + + {selected && ( + +
+ + setAgent({ ...agent, name: e.target.value })} className="w-40" placeholder="Aria" /> + + + setAgent({ ...agent, monogram: e.target.value })} className="w-20" placeholder="AR" /> + +
+ +
+ +
+ {AUTONOMY.map((a) => ( + + ))} +
+
+ + + + + +
+ +
+ {skills.map((skill) => ( + + ))} + {skills.length === 0 &&

No skills indexed yet.

} +
+
+ + + setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" /> + + + +
+ )} +
+
+ )} +
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +}