UI completion pass + accountability & benchmarking

UI (daily-drivable now):
- Board: dnd-kit drag-and-drop between columns; click a card → task detail drawer (Sheet)
  with status, member assignee picker, send-to-AI-seat dispatch, description/artifact,
  parent/children navigation; seat-triad assignee chips (AI indigo monogram / human slate).
- Cartable page (the personal pending slice), Members & invitations page (invite + copy
  join token; V1 sends no email), Review inbox now shows a word-level diff of your edits
  vs the proposal (lib/diff.ts, LCS), Org chart page (React Flow: org → teams → seats in
  the human/open/AI triad). Nav reordered; nothing left "soon".

Accountability & benchmarking:
- Identity: GET /members (directory + org role) and GET /invitations (with join token,
  inviter-only) — the directory also resolves names client-side everywhere.
- OrgBoard: work_item_transitions recorded on every status change (AddWorkItemTransitions
  migration); GET /performance — per assignee (human and AI on the same scale): pending by
  column, done, worked hours (time in InProgress), avg cycle time (start of work → done),
  plus the unassigned-pending count. Owner-level capability.
- Performance page: benchmark table merging board metrics with AI trust metrics (approval
  rate + edit distance from analytics); flags work with no one accountable.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 43/43 (new: directory,
invitations list + Member 403s, transition-derived worked-hours/cycle-time, unassigned
count); client npm build green (TS strict).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 12:54:13 +03:30
parent 82033c2733
commit d853609213
21 changed files with 1907 additions and 130 deletions
+8
View File
@@ -2,7 +2,11 @@ import { Navigate, Route, Routes } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage'
import { CartablePage } from '@/pages/CartablePage'
import { LoginPage } from '@/pages/LoginPage'
import { MembersPage } from '@/pages/MembersPage'
import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { useAuth } from '@/store/auth'
@@ -18,6 +22,10 @@ export default function App() {
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+17 -4
View File
@@ -1,6 +1,17 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import { Bot, ChartColumn, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import {
Bot,
ChartColumn,
Gauge,
Inbox,
type LucideIcon,
LayoutDashboard,
LogOut,
Network,
ShieldCheck,
Users,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
@@ -27,11 +38,13 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="flex flex-1 flex-col gap-1 p-3">
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Users} label="Members" to="/members" />
<NavItem icon={Gauge} label="Performance" to="/performance" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
<Separator className="bg-sidebar-border" />
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
function SheetContent({
className,
children,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content>) {
return (
<SheetPrimitive.Portal>
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0"
/>
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-lg flex-col gap-4 overflow-y-auto border-l bg-background p-6 shadow-lg data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-lg font-semibold tracking-tight", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger }
+59
View File
@@ -0,0 +1,59 @@
export interface DiffSegment {
kind: 'same' | 'removed' | 'added'
text: string
}
const MAX_TOKENS = 1500
/**
* Word-level diff (LCS) between two texts — used by the review inbox to show what the reviewer
* changed vs the agent's proposal. Inputs are capped so the O(n·m) table stays cheap.
*/
export function diffWords(before: string, after: string): DiffSegment[] {
const a = tokenize(before).slice(0, MAX_TOKENS)
const b = tokenize(after).slice(0, MAX_TOKENS)
// LCS length table.
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
// Walk the table, merging consecutive segments of the same kind.
const segments: DiffSegment[] = []
const push = (kind: DiffSegment['kind'], text: string) => {
const last = segments[segments.length - 1]
if (last && last.kind === kind) {
last.text += text
} else {
segments.push({ kind, text })
}
}
let i = 0
let j = 0
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push('same', a[i])
i++
j++
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
push('removed', a[i])
i++
} else {
push('added', b[j])
j++
}
}
while (i < a.length) push('removed', a[i++])
while (j < b.length) push('added', b[j++])
return segments
}
/** Splits text into words + whitespace separators (kept, so the diff re-renders faithfully). */
function tokenize(text: string): string[] {
return text.split(/(\s+)/).filter((t) => t.length > 0)
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { api } from './api'
export interface MemberRow {
id: string
email: string
displayName: string
role: string | null
}
export interface SeatRow {
id: string
teamId: string
roleName: string
state: string
memberId: string | null
agentId: string | null
}
/** The org member directory — for assignee pickers and name resolution. */
export function useMembers(organizationId: string | null) {
const [members, setMembers] = useState<MemberRow[]>([])
useEffect(() => {
if (!organizationId) return
api
.get<MemberRow[]>(`/api/identity/members?organizationId=${organizationId}`)
.then(setMembers)
.catch(() => setMembers([]))
}, [organizationId])
return members
}
/** The team's seats — for AI dispatch pickers and agent-name resolution on cards. */
export function useSeats(teamId: string | null) {
const [seats, setSeats] = useState<SeatRow[]>([])
useEffect(() => {
if (!teamId) {
setSeats([])
return
}
api
.get<SeatRow[]>(`/api/orgboard/seats?teamId=${teamId}`)
.then(setSeats)
.catch(() => setSeats([]))
}, [teamId])
return seats
}
+351 -125
View File
@@ -1,9 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, UserPlus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
DndContext,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import { Bot, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { StatusDot } from '@/components/StatusDot'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -23,7 +30,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { api } from '@/lib/api'
import { useMembers, useSeats, type MemberRow, type SeatRow } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
const COLUMNS = [
@@ -33,20 +42,22 @@ const COLUMNS = [
{ value: 'Done', label: 'Done' },
] as const
interface Team {
id: string
organizationId: string
name: string
}
interface Task {
export interface Task {
id: string
teamId: string
title: string
description?: string | null
type: string
status: string
assigneeKind: string
assigneeId?: string | null
parentId?: string | null
}
interface Team {
id: string
organizationId: string
name: string
}
interface Board {
@@ -56,21 +67,23 @@ interface Board {
export function BoardPage() {
const memberId = useAuth((s) => s.memberId)
const email = useAuth((s) => s.email)
const organizationId = useAuth((s) => s.organizationId)
const [orgName, setOrgName] = useState('')
const [teams, setTeams] = useState<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null)
const [board, setBoard] = useState<Board | null>(null)
const [cartable, setCartable] = useState<Task[]>([])
const [newTeam, setNewTeam] = useState('')
const [newTask, setNewTask] = useState('')
const [openTaskId, setOpenTaskId] = useState<string | null>(null)
const members = useMembers(organizationId)
const seats = useSeats(teamId)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
const loadTeams = useCallback(async () => {
if (!organizationId) {
return
}
if (!organizationId) return
try {
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(result)
@@ -83,7 +96,6 @@ export function BoardPage() {
const loadBoard = useCallback(async (id: string) => {
try {
setBoard(await api.get<Board>(`/api/orgboard/board?teamId=${id}`))
setCartable(await api.get<Task[]>('/api/orgboard/cartable'))
} catch (err) {
toast.error((err as Error).message)
}
@@ -94,9 +106,7 @@ export function BoardPage() {
}, [loadTeams])
useEffect(() => {
if (teamId) {
void loadBoard(teamId)
}
if (teamId) void loadBoard(teamId)
}, [teamId, loadBoard])
async function run(action: () => Promise<unknown>) {
@@ -123,9 +133,7 @@ export function BoardPage() {
const createTask = () =>
run(async () => {
if (!teamId) {
return
}
if (!teamId || !newTask.trim()) return
await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' })
setNewTask('')
await loadBoard(teamId)
@@ -134,27 +142,28 @@ export function BoardPage() {
const move = (id: string, status: string) =>
run(async () => {
await api.patch(`/api/orgboard/tasks/${id}/move`, { status })
if (teamId) {
await loadBoard(teamId)
}
if (teamId) await loadBoard(teamId)
})
const assignToMe = (id: string) =>
run(async () => {
await api.patch(`/api/orgboard/tasks/${id}/assign`, { memberId })
if (teamId) {
await loadBoard(teamId)
}
})
const allTasks = useMemo(() => board?.columns.flatMap((c) => c.items) ?? [], [board])
const openTask = allTasks.find((t) => t.id === openTaskId) ?? null
const initials = (email ?? '?').slice(0, 2).toUpperCase()
const onDragEnd = (event: DragEndEvent) => {
const taskId = String(event.active.id)
const target = event.over ? String(event.over.id) : null
if (!target) return
const current = allTasks.find((t) => t.id === taskId)
if (current && current.status !== target) void move(taskId, target)
}
return (
<AppShell>
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Board</h1>
<p className="text-sm text-muted-foreground">{orgName || 'Your organization'}</p>
<p className="text-sm text-muted-foreground">
Drag cards between columns click a card for details.
</p>
</header>
<Card>
@@ -212,9 +221,7 @@ export function BoardPage() {
placeholder="New task title…"
className="max-w-md"
onKeyDown={(e) => {
if (e.key === 'Enter') {
createTask()
}
if (e.key === 'Enter') createTask()
}}
/>
<Button onClick={createTask}>
@@ -224,94 +231,313 @@ export function BoardPage() {
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((column) => {
const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
return (
<Card key={column.value} className="bg-muted/30">
<CardHeader>
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{column.label}
<Badge variant="secondary">{items.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{items.map((task) => {
const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId
return (
<Card key={task.id}>
<CardContent className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-snug">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
<div className="flex items-center justify-between gap-2">
<Select value={task.status} onValueChange={(v) => move(task.id, v)}>
<SelectTrigger className="h-8 w-[136px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COLUMNS.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{task.assigneeKind === 'Member' ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<StatusDot tone="human" />
<Avatar className="size-6">
<AvatarFallback className="text-[10px]">
{mine ? initials : '··'}
</AvatarFallback>
</Avatar>
{mine ? 'You' : 'Assigned'}
</span>
) : (
<Button variant="outline" size="sm" onClick={() => assignToMe(task.id)}>
<UserPlus data-icon="inline-start" />
Assign to me
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
{items.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">No tasks</p>
)}
</CardContent>
</Card>
)
})}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">My cartable</CardTitle>
<CardDescription>Tasks assigned to you across teams.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{cartable.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span>{task.title}</span>
<Badge variant="secondary">{task.status}</Badge>
</div>
))}
{cartable.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing assigned to you yet.</p>
)}
</CardContent>
</Card>
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((column) => {
const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
return (
<DroppableColumn key={column.value} status={column.value} label={column.label} count={items.length}>
{items.map((task) => (
<DraggableCard
key={task.id}
task={task}
memberId={memberId}
members={members}
seats={seats}
onOpen={() => setOpenTaskId(task.id)}
/>
))}
{items.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">No tasks</p>}
</DroppableColumn>
)
})}
</div>
</DndContext>
</div>
<TaskDrawer
task={openTask}
allTasks={allTasks}
members={members}
seats={seats}
onClose={() => setOpenTaskId(null)}
onChanged={() => teamId && loadBoard(teamId)}
onOpenTask={(id) => setOpenTaskId(id)}
/>
</AppShell>
)
}
function DroppableColumn({
status,
label,
count,
children,
}: {
status: string
label: string
count: number
children: React.ReactNode
}) {
const { setNodeRef, isOver } = useDroppable({ id: status })
return (
<Card ref={setNodeRef} className={`bg-muted/30 transition-shadow ${isOver ? 'ring-2 ring-ring' : ''}`}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{label}
<Badge variant="secondary">{count}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex min-h-24 flex-col gap-3">{children}</CardContent>
</Card>
)
}
function DraggableCard({
task,
memberId,
members,
seats,
onOpen,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
onOpen: () => void
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id })
const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={onOpen}
className={`cursor-pointer ${isDragging ? 'relative z-50 opacity-70' : ''}`}
>
<Card className="hover:border-ring/50">
<CardContent className="flex flex-col gap-2 py-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-snug">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
<AssigneeChip task={task} memberId={memberId} members={members} seats={seats} />
</CardContent>
</Card>
</div>
)
}
/** The seat-state triad on cards: AI = indigo monogram, human = slate, unassigned = muted. */
function AssigneeChip({
task,
memberId,
members,
seats,
}: {
task: Task
memberId: string | null
members: MemberRow[]
seats: SeatRow[]
}) {
if (task.assigneeKind === 'Agent') {
const seat = seats.find((s) => s.agentId === task.assigneeId)
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="grid size-5 place-items-center rounded bg-seat-ai text-[9px] font-bold text-white">AI</span>
{seat?.roleName ?? 'AI seat'}
</span>
)
}
if (task.assigneeKind === 'Member') {
const member = members.find((m) => m.id === task.assigneeId)
const label = task.assigneeId === memberId ? 'You' : (member?.displayName ?? 'Member')
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="grid size-5 place-items-center rounded bg-seat-human text-[9px] font-bold text-white">
{(member?.displayName ?? '?').slice(0, 2).toUpperCase()}
</span>
{label}
</span>
)
}
return <span className="text-xs text-muted-foreground/60">Unassigned</span>
}
function TaskDrawer({
task,
allTasks,
members,
seats,
onClose,
onChanged,
onOpenTask,
}: {
task: Task | null
allTasks: Task[]
members: MemberRow[]
seats: SeatRow[]
onClose: () => void
onChanged: () => void
onOpenTask: (id: string) => void
}) {
const [busy, setBusy] = useState(false)
const [seatId, setSeatId] = useState<string>('')
const aiSeats = seats.filter((s) => s.state === 'Ai')
if (!task) {
return null
}
const children = allTasks.filter((t) => t.parentId === task.id)
const parent = task.parentId ? allTasks.find((t) => t.id === task.parentId) : null
async function act(action: () => Promise<unknown>, success?: string) {
setBusy(true)
try {
await action()
if (success) toast.success(success)
onChanged()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent>
<SheetHeader>
<div className="flex items-center gap-2">
<Badge variant="outline">{task.type}</Badge>
<Badge variant="secondary">{task.status}</Badge>
</div>
<SheetTitle>{task.title}</SheetTitle>
<SheetDescription>
{parent ? (
<button type="button" className="text-primary hover:underline" onClick={() => onOpenTask(parent.id)}>
{parent.title}
</button>
) : (
'Top-level task'
)}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2">
<Label>Status</Label>
<Select
value={task.status}
onValueChange={(v) => act(() => api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: v }))}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COLUMNS.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Assignee</Label>
<Select
value={task.assigneeKind === 'Member' ? (task.assigneeId ?? '') : ''}
onValueChange={(v) =>
act(() => api.patch(`/api/orgboard/tasks/${task.id}/assign`, { memberId: v }), 'Assigned.')
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={task.assigneeKind === 'Agent' ? 'Assigned to an AI seat' : 'Pick a member'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{members.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.displayName} ({m.email})
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{aiSeats.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Send to an AI seat</Label>
<div className="flex gap-2">
<Select value={seatId} onValueChange={setSeatId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Pick a seat" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{aiSeats.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.roleName}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
disabled={busy || !seatId}
onClick={() =>
act(
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
'Dispatched — the proposal will land in the review inbox.',
)
}
>
<Bot data-icon="inline-start" />
Run
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label>Description / artifact</Label>
{task.description ? (
<div className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-xs leading-relaxed">
{task.description}
</div>
) : (
<p className="text-sm text-muted-foreground">No description yet approved agent artifacts land here.</p>
)}
</div>
{children.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Child tasks</Label>
<div className="flex flex-col gap-1.5">
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onOpenTask(child.id)}
className="flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm hover:border-ring/60"
>
<span className="truncate">{child.title}</span>
<Badge variant="secondary">{child.status}</Badge>
</button>
))}
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { api } from '@/lib/api'
interface Task {
id: string
teamId: string
title: string
type: string
status: string
}
const GROUPS = [
{ status: 'InProgress', label: 'In progress' },
{ status: 'InReview', label: 'In review' },
{ status: 'Backlog', label: 'Backlog' },
{ status: 'Done', label: 'Recently done' },
] as const
/** The cartable: one person's pending slice — everything assigned to them, most urgent first. */
export function CartablePage() {
const [tasks, setTasks] = useState<Task[] | null>(null)
const load = useCallback(async () => {
try {
setTasks(await api.get<Task[]>('/api/orgboard/cartable'))
} catch (err) {
toast.error((err as Error).message)
setTasks([])
}
}, [])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-3xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Cartable</h1>
<p className="text-sm text-muted-foreground">Everything waiting on you, across all your teams.</p>
</div>
{tasks === null && (
<div className="flex flex-col gap-4">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
)}
{tasks?.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Nothing assigned to you yet.
</CardContent>
</Card>
)}
<div className="flex flex-col gap-4">
{tasks &&
tasks.length > 0 &&
GROUPS.map((group) => {
const items = tasks.filter((t) => t.status === group.status)
if (items.length === 0) return null
return (
<Card key={group.status}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
<Badge variant="secondary">{items.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{items.map((task) => (
<div
key={task.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="truncate">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
</div>
))}
</CardContent>
</Card>
)
})}
</div>
</div>
</AppShell>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, UserPlus } 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 { api } from '@/lib/api'
import { useMembers } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
interface Invitation {
id: string
email: string
scopeType: string
scopeId: string
role: string
status: string
token: string
createdAtUtc: string
}
const ROLES = ['Member', 'TeamOwner', 'Viewer', 'Owner'] as const
export function MembersPage() {
const organizationId = useAuth((s) => s.organizationId)
const members = useMembers(organizationId)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('Member')
const [busy, setBusy] = useState(false)
const loadInvitations = useCallback(async () => {
if (!organizationId) return
try {
setInvitations(await api.get<Invitation[]>(`/api/identity/invitations?organizationId=${organizationId}`))
} catch {
setInvitations([]) // non-owners simply don't see the invitations panel
}
}, [organizationId])
useEffect(() => {
void loadInvitations()
}, [loadInvitations])
async function invite() {
if (!organizationId || !email.trim()) return
setBusy(true)
try {
await api.post('/api/identity/invitations', {
email,
scopeType: 'Organization',
scopeId: organizationId,
role,
organizationId,
})
setEmail('')
toast.success('Invitation created — copy the join token below.')
await loadInvitations()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
async function copyToken(invitation: Invitation) {
await navigator.clipboard.writeText(invitation.token)
toast.success('Join token copied — share it; they accept on the login page.')
}
return (
<AppShell>
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Members</h1>
<p className="text-sm text-muted-foreground">Who's in the org, and who's invited.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Invite someone</CardTitle>
<CardDescription>
V1 sends no email share the join token; they redeem it from the login page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-64"
placeholder="dev@company.com"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button onClick={invite} disabled={busy || !email.trim()}>
<UserPlus data-icon="inline-start" />
Invite
</Button>
</CardContent>
</Card>
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Invitations</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{invitation.email}</div>
<div className="text-xs text-muted-foreground">
{invitation.role} · {new Date(invitation.createdAtUtc).toLocaleDateString()}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={invitation.status === 'Pending' ? 'outline' : 'secondary'}>
{invitation.status}
</Badge>
{invitation.status === 'Pending' && (
<Button variant="outline" size="sm" onClick={() => copyToken(invitation)}>
<Copy data-icon="inline-start" />
Copy token
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Members ({members.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="grid size-6 place-items-center rounded bg-seat-human text-[10px] font-bold text-white">
{member.displayName.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{member.displayName}</span>
<span className="text-muted-foreground">{member.email}</span>
</span>
<Badge variant="secondary">{member.role ?? 'Member'}</Badge>
</div>
))}
</CardContent>
</Card>
</div>
</AppShell>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react'
import { Background, ReactFlow, type Edge, type Node } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
import type { SeatRow } from '@/lib/useDirectory'
interface Team {
id: string
organizationId: string
name: string
}
const TEAM_WIDTH = 280
const SEAT_HEIGHT = 64
/** The live org chart: org → teams → seats, painted with the human/open/AI triad. */
export function OrgChartPage() {
const organizationId = useAuth((s) => s.organizationId)
const [teams, setTeams] = useState<Team[]>([])
const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({})
useEffect(() => {
if (!organizationId) return
void (async () => {
try {
const teamList = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(teamList)
const entries = await Promise.all(
teamList.map(async (team) => {
try {
return [team.id, await api.get<SeatRow[]>(`/api/orgboard/seats?teamId=${team.id}`)] as const
} catch {
return [team.id, []] as const
}
}),
)
setSeatsByTeam(Object.fromEntries(entries))
} catch (err) {
toast.error((err as Error).message)
}
})()
}, [organizationId])
const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam])
return (
<AppShell>
<div className="flex h-full flex-col p-6">
<div className="mb-4">
<h1 className="text-2xl font-semibold tracking-tight">Org chart</h1>
<p className="text-sm text-muted-foreground">
The live org: <span className="font-medium text-seat-human">human</span> ·{' '}
<span className="font-medium text-seat-open">open</span> ·{' '}
<span className="font-medium text-seat-ai">AI</span> seats.
</p>
</div>
<div className="min-h-[480px] flex-1 overflow-hidden rounded-xl border bg-background">
<ReactFlow nodes={nodes} edges={edges} fitView proOptions={{ hideAttribution: true }}>
<Background gap={20} />
</ReactFlow>
</div>
</div>
</AppShell>
)
}
function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
if (teams.length === 0) {
return { nodes, edges }
}
const totalWidth = teams.length * TEAM_WIDTH
nodes.push({
id: 'org',
position: { x: totalWidth / 2 - 90, y: 0 },
data: { label: 'Organization' },
style: {
background: 'var(--color-sidebar, #1e1b4b)',
color: 'white',
fontWeight: 600,
borderRadius: 10,
border: 'none',
width: 180,
},
})
teams.forEach((team, teamIndex) => {
const x = teamIndex * TEAM_WIDTH
nodes.push({
id: team.id,
position: { x, y: 110 },
data: { label: team.name },
style: { borderRadius: 10, fontWeight: 600, width: 200 },
})
edges.push({ id: `org-${team.id}`, source: 'org', target: team.id })
const seats = seatsByTeam[team.id] ?? []
seats.forEach((seat, seatIndex) => {
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
nodes.push({
id: seat.id,
position: { x: x + 10, y: 210 + seatIndex * SEAT_HEIGHT },
data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
style: {
background: color,
color: 'white',
borderRadius: 8,
border: 'none',
fontSize: 12,
width: 180,
},
})
edges.push({ id: `${team.id}-${seat.id}`, source: team.id, target: seat.id })
})
})
return { nodes, edges }
}
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { api } from '@/lib/api'
import { useMembers } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
interface PerformanceRow {
assigneeKind: 'Member' | 'Agent'
assigneeId: string
name: string | null
backlog: number
inProgress: number
inReview: number
done: number
workedHours: number
avgCycleHours: number | null
}
interface Performance {
unassignedPending: number
rows: PerformanceRow[]
}
interface AgentAnalytics {
agentId: string
approvalRate: number | null
avgEditDistance: number | null
reviews: number
}
interface Analytics {
agents: AgentAnalytics[]
}
/**
* Accountability & benchmarking: humans and AI on the same scale — who owns what (pending load),
* hours worked (time in progress), throughput, cycle time — and for AI seats, the trust metrics
* (approval rate + edit distance) alongside.
*/
export function PerformancePage() {
const organizationId = useAuth((s) => s.organizationId)
const members = useMembers(organizationId)
const [performance, setPerformance] = useState<Performance | null>(null)
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
setPerformance(await api.get<Performance>(`/api/orgboard/performance?organizationId=${organizationId}`))
} catch (err) {
toast.error((err as Error).message)
setPerformance({ unassignedPending: 0, rows: [] })
}
try {
setAnalytics(await api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`))
} catch {
setAnalytics({ agents: [] })
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
const rows = (performance?.rows ?? []).map((row) => {
const name =
row.name ??
members.find((m) => m.id === row.assigneeId)?.displayName ??
(row.assigneeKind === 'Agent' ? 'AI agent' : 'Member')
const agentMetrics =
row.assigneeKind === 'Agent' ? analytics?.agents.find((a) => a.agentId === row.assigneeId) : undefined
return { ...row, name, agentMetrics, pending: row.backlog + row.inProgress + row.inReview }
})
return (
<AppShell>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team performance</h1>
<p className="text-sm text-muted-foreground">
Who's accountable for what — humans and AI benchmarked on the same scale.
</p>
</div>
{performance === null ? (
<Skeleton className="h-64 w-full" />
) : (
<>
{performance.unassignedPending > 0 && (
<Card className="border-seat-open/50">
<CardContent className="flex items-center justify-between py-4 text-sm">
<span>
<span className="font-semibold">{performance.unassignedPending}</span> pending task
{performance.unassignedPending === 1 ? '' : 's'} with <strong>no one accountable</strong>.
</span>
<Badge variant="outline" className="border-seat-open text-seat-open">
needs an owner
</Badge>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Benchmark</CardTitle>
<CardDescription>
Working hours = time tasks spent In&nbsp;Progress · cycle time = start of work → done. Approval
rate and edit distance apply to AI seats.
</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No assigned work yet — assign tasks to people or AI seats to populate this view.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 font-medium">Assignee</th>
<th className="py-2 font-medium">Pending</th>
<th className="py-2 font-medium">Done</th>
<th className="py-2 font-medium">Worked (h)</th>
<th className="py-2 font-medium">Cycle (h)</th>
<th className="py-2 font-medium">Approval</th>
<th className="py-2 font-medium">Edit dist.</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.assigneeKind}-${row.assigneeId}`} className="border-b last:border-0">
<td className="py-2.5">
<span className="flex items-center gap-2">
<span
className={`grid size-6 shrink-0 place-items-center rounded text-[10px] font-bold text-white ${
row.assigneeKind === 'Agent' ? 'bg-seat-ai' : 'bg-seat-human'
}`}
>
{row.assigneeKind === 'Agent' ? 'AI' : row.name.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{row.name}</span>
</span>
</td>
<td className="py-2.5">
<span title={`Backlog ${row.backlog} · In progress ${row.inProgress} · In review ${row.inReview}`}>
{row.pending}
</span>
</td>
<td className="py-2.5">{row.done}</td>
<td className="py-2.5">{row.workedHours.toFixed(1)}</td>
<td className="py-2.5">{row.avgCycleHours?.toFixed(1) ?? ''}</td>
<td className="py-2.5">
{row.agentMetrics?.approvalRate != null
? `${Math.round(row.agentMetrics.approvalRate * 100)}%`
: row.assigneeKind === 'Agent'
? ''
: 'n/a'}
</td>
<td className="py-2.5">
{row.agentMetrics?.avgEditDistance != null
? row.agentMetrics.avgEditDistance.toFixed(3)
: row.assigneeKind === 'Agent'
? ''
: 'n/a'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</AppShell>
)
}
+22
View File
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { diffWords } from '@/lib/diff'
import { useAuth } from '@/store/auth'
interface ReviewItem {
@@ -156,6 +157,27 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
/>
</div>
{content !== item.content && (
<div className="flex flex-col gap-2">
<Label>Your edits (vs the proposal)</Label>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed">
{diffWords(item.content, content).map((segment, i) =>
segment.kind === 'same' ? (
<span key={i}>{segment.text}</span>
) : segment.kind === 'removed' ? (
<del key={i} className="rounded bg-destructive/15 text-destructive">
{segment.text}
</del>
) : (
<ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline">
{segment.text}
</ins>
),
)}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
<Textarea