diff --git a/client/src/App.tsx b/client/src/App.tsx index 6171271..088e621 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { : } /> : } /> : } /> + : } /> + : } /> + : } /> + : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 4c48a79..abcd402 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -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 }) { diff --git a/client/src/components/ui/sheet.tsx b/client/src/components/ui/sheet.tsx new file mode 100644 index 0000000..282e497 --- /dev/null +++ b/client/src/components/ui/sheet.tsx @@ -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) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } diff --git a/client/src/lib/diff.ts b/client/src/lib/diff.ts new file mode 100644 index 0000000..15c1098 --- /dev/null +++ b/client/src/lib/diff.ts @@ -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(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) +} diff --git a/client/src/lib/useDirectory.ts b/client/src/lib/useDirectory.ts new file mode 100644 index 0000000..a3d7bf5 --- /dev/null +++ b/client/src/lib/useDirectory.ts @@ -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([]) + useEffect(() => { + if (!organizationId) return + api + .get(`/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([]) + useEffect(() => { + if (!teamId) { + setSeats([]) + return + } + api + .get(`/api/orgboard/seats?teamId=${teamId}`) + .then(setSeats) + .catch(() => setSeats([])) + }, [teamId]) + return seats +} diff --git a/client/src/pages/BoardPage.tsx b/client/src/pages/BoardPage.tsx index 2d62bf2..7b3c872 100644 --- a/client/src/pages/BoardPage.tsx +++ b/client/src/pages/BoardPage.tsx @@ -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([]) const [teamId, setTeamId] = useState(null) const [board, setBoard] = useState(null) - const [cartable, setCartable] = useState([]) const [newTeam, setNewTeam] = useState('') const [newTask, setNewTask] = useState('') + const [openTaskId, setOpenTaskId] = useState(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(`/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(`/api/orgboard/board?teamId=${id}`)) - setCartable(await api.get('/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) { @@ -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 (

Board

-

{orgName || 'Your organization'}

+

+ Drag cards between columns — click a card for details. +

@@ -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() }} />
)} -
- {COLUMNS.map((column) => { - const items = board?.columns.find((c) => c.status === column.value)?.items ?? [] - return ( - - - - {column.label} - {items.length} - - - - {items.map((task) => { - const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId - return ( - - -
- {task.title} - {task.type} -
-
- - - {task.assigneeKind === 'Member' ? ( - - - - - {mine ? initials : '··'} - - - {mine ? 'You' : 'Assigned'} - - ) : ( - - )} -
-
-
- ) - })} - {items.length === 0 && ( -

No tasks

- )} -
-
- ) - })} -
- - - - My cartable - Tasks assigned to you across teams. - - - {cartable.map((task) => ( -
- {task.title} - {task.status} -
- ))} - {cartable.length === 0 && ( -

Nothing assigned to you yet.

- )} -
-
+ +
+ {COLUMNS.map((column) => { + const items = board?.columns.find((c) => c.status === column.value)?.items ?? [] + return ( + + {items.map((task) => ( + setOpenTaskId(task.id)} + /> + ))} + {items.length === 0 &&

No tasks

} +
+ ) + })} +
+
+ + setOpenTaskId(null)} + onChanged={() => teamId && loadBoard(teamId)} + onOpenTask={(id) => setOpenTaskId(id)} + /> ) } + +function DroppableColumn({ + status, + label, + count, + children, +}: { + status: string + label: string + count: number + children: React.ReactNode +}) { + const { setNodeRef, isOver } = useDroppable({ id: status }) + return ( + + + + {label} + {count} + + + {children} + + ) +} + +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 ( +
+ + +
+ {task.title} + {task.type} +
+ +
+
+
+ ) +} + +/** 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 ( + + AI + {seat?.roleName ?? 'AI seat'} + + ) + } + + if (task.assigneeKind === 'Member') { + const member = members.find((m) => m.id === task.assigneeId) + const label = task.assigneeId === memberId ? 'You' : (member?.displayName ?? 'Member') + return ( + + + {(member?.displayName ?? '?').slice(0, 2).toUpperCase()} + + {label} + + ) + } + + return Unassigned +} + +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('') + 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, 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 ( + !open && onClose()}> + + +
+ {task.type} + {task.status} +
+ {task.title} + + {parent ? ( + + ) : ( + 'Top-level task' + )} + +
+ +
+ + +
+ +
+ + +
+ + {aiSeats.length > 0 && ( +
+ +
+ + +
+
+ )} + +
+ + {task.description ? ( +
+ {task.description} +
+ ) : ( +

No description yet — approved agent artifacts land here.

+ )} +
+ + {children.length > 0 && ( +
+ +
+ {children.map((child) => ( + + ))} +
+
+ )} +
+
+ ) +} diff --git a/client/src/pages/CartablePage.tsx b/client/src/pages/CartablePage.tsx new file mode 100644 index 0000000..5185983 --- /dev/null +++ b/client/src/pages/CartablePage.tsx @@ -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(null) + + const load = useCallback(async () => { + try { + setTasks(await api.get('/api/orgboard/cartable')) + } catch (err) { + toast.error((err as Error).message) + setTasks([]) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + return ( + +
+
+

Cartable

+

Everything waiting on you, across all your teams.

+
+ + {tasks === null && ( +
+ + +
+ )} + + {tasks?.length === 0 && ( + + + Nothing assigned to you yet. + + + )} + +
+ {tasks && + tasks.length > 0 && + GROUPS.map((group) => { + const items = tasks.filter((t) => t.status === group.status) + if (items.length === 0) return null + return ( + + + + {group.label} + {items.length} + + + + {items.map((task) => ( +
+ {task.title} + {task.type} +
+ ))} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/client/src/pages/MembersPage.tsx b/client/src/pages/MembersPage.tsx new file mode 100644 index 0000000..3138d37 --- /dev/null +++ b/client/src/pages/MembersPage.tsx @@ -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([]) + const [email, setEmail] = useState('') + const [role, setRole] = useState('Member') + const [busy, setBusy] = useState(false) + + const loadInvitations = useCallback(async () => { + if (!organizationId) return + try { + setInvitations(await api.get(`/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 ( + +
+
+

Members

+

Who's in the org, and who's invited.

+
+ + + + Invite someone + + V1 sends no email — share the join token; they redeem it from the login page. + + + +
+ + setEmail(e.target.value)} + className="w-64" + placeholder="dev@company.com" + /> +
+
+ + +
+ +
+
+ + {invitations.length > 0 && ( + + + Invitations + + + {invitations.map((invitation) => ( +
+
+
{invitation.email}
+
+ {invitation.role} · {new Date(invitation.createdAtUtc).toLocaleDateString()} +
+
+
+ + {invitation.status} + + {invitation.status === 'Pending' && ( + + )} +
+
+ ))} +
+
+ )} + + + + Members ({members.length}) + + + {members.map((member) => ( +
+ + + {member.displayName.slice(0, 2).toUpperCase()} + + {member.displayName} + {member.email} + + {member.role ?? 'Member'} +
+ ))} +
+
+
+
+ ) +} diff --git a/client/src/pages/OrgChartPage.tsx b/client/src/pages/OrgChartPage.tsx new file mode 100644 index 0000000..52625fe --- /dev/null +++ b/client/src/pages/OrgChartPage.tsx @@ -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([]) + const [seatsByTeam, setSeatsByTeam] = useState>({}) + + useEffect(() => { + if (!organizationId) return + void (async () => { + try { + const teamList = await api.get(`/api/orgboard/teams?organizationId=${organizationId}`) + setTeams(teamList) + const entries = await Promise.all( + teamList.map(async (team) => { + try { + return [team.id, await api.get(`/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 ( + +
+
+

Org chart

+

+ The live org: human ·{' '} + open ·{' '} + AI seats. +

+
+
+ + + +
+
+
+ ) +} + +function buildGraph(teams: Team[], seatsByTeam: Record): { 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 } +} diff --git a/client/src/pages/PerformancePage.tsx b/client/src/pages/PerformancePage.tsx new file mode 100644 index 0000000..213655b --- /dev/null +++ b/client/src/pages/PerformancePage.tsx @@ -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(null) + const [analytics, setAnalytics] = useState(null) + + const load = useCallback(async () => { + if (!organizationId) return + try { + setPerformance(await api.get(`/api/orgboard/performance?organizationId=${organizationId}`)) + } catch (err) { + toast.error((err as Error).message) + setPerformance({ unassignedPending: 0, rows: [] }) + } + try { + setAnalytics(await api.get(`/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 ( + +
+
+

Team performance

+

+ Who's accountable for what — humans and AI benchmarked on the same scale. +

+
+ + {performance === null ? ( + + ) : ( + <> + {performance.unassignedPending > 0 && ( + + + + {performance.unassignedPending} pending task + {performance.unassignedPending === 1 ? '' : 's'} with no one accountable. + + + needs an owner + + + + )} + + + + Benchmark + + Working hours = time tasks spent In Progress · cycle time = start of work → done. Approval + rate and edit distance apply to AI seats. + + + + {rows.length === 0 ? ( +

+ No assigned work yet — assign tasks to people or AI seats to populate this view. +

+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
AssigneePendingDoneWorked (h)Cycle (h)ApprovalEdit dist.
+ + + {row.assigneeKind === 'Agent' ? 'AI' : row.name.slice(0, 2).toUpperCase()} + + {row.name} + + + + {row.pending} + + {row.done}{row.workedHours.toFixed(1)}{row.avgCycleHours?.toFixed(1) ?? '—'} + {row.agentMetrics?.approvalRate != null + ? `${Math.round(row.agentMetrics.approvalRate * 100)}%` + : row.assigneeKind === 'Agent' + ? '—' + : 'n/a'} + + {row.agentMetrics?.avgEditDistance != null + ? row.agentMetrics.avgEditDistance.toFixed(3) + : row.assigneeKind === 'Agent' + ? '—' + : 'n/a'} +
+
+ )} +
+
+ + )} +
+
+ ) +} diff --git a/client/src/pages/ReviewsPage.tsx b/client/src/pages/ReviewsPage.tsx index 9dc9b81..fca9523 100644 --- a/client/src/pages/ReviewsPage.tsx +++ b/client/src/pages/ReviewsPage.tsx @@ -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 /> + {content !== item.content && ( +
+ +
+ {diffWords(item.content, content).map((segment, i) => + segment.kind === 'same' ? ( + {segment.text} + ) : segment.kind === 'removed' ? ( + + {segment.text} + + ) : ( + + {segment.text} + + ), + )} +
+
+ )} +