diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6002ee61..83025d87 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -20,6 +20,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { OrgChart } from "./pages/OrgChart"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; @@ -95,7 +96,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 243e01fd..6931a1f4 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclip/shared"; import { Button } from "@/components/ui/button"; @@ -18,15 +18,46 @@ interface CommentThreadProps { issueStatus?: string; agentMap?: Map; imageUploadHandler?: (file: File) => Promise; + draftKey?: string; } const CLOSED_STATUSES = new Set(["done", "cancelled"]); +const DRAFT_DEBOUNCE_MS = 800; -export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler }: CommentThreadProps) { +function loadDraft(draftKey: string): string { + try { + return localStorage.getItem(draftKey) ?? ""; + } catch { + return ""; + } +} + +function saveDraft(draftKey: string, value: string) { + try { + if (value.trim()) { + localStorage.setItem(draftKey, value); + } else { + localStorage.removeItem(draftKey); + } + } catch { + // Ignore localStorage failures. + } +} + +function clearDraft(draftKey: string) { + try { + localStorage.removeItem(draftKey); + } catch { + // Ignore localStorage failures. + } +} + +export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler, draftKey }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const editorRef = useRef(null); + const draftTimer = useRef | null>(null); const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false; @@ -47,6 +78,25 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl })); }, [agentMap]); + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + async function handleSubmit() { const trimmed = body.trim(); if (!trimmed) return; @@ -55,6 +105,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl try { await onAdd(trimmed, isClosed && reopen ? true : undefined); setBody(""); + if (draftKey) clearDraft(draftKey); setReopen(false); } finally { setSubmitting(false); diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index cfcf6944..7b9bab0d 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -127,8 +127,12 @@ export function InlineEditor({ ); } + // Use div instead of Tag when rendering markdown to avoid invalid nesting + // (e.g.

cannot contain the

/

elements that markdown produces) + const DisplayTag = value && multiline ? "div" : Tag; + return ( - + ); } diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 04d6cd72..1c369ab8 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -253,9 +253,9 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { @@ -273,7 +273,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", a.id === issue.assigneeAgentId && "bg-accent" )} - onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }} + onClick={() => { onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }} > {a.name} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index d8dd3cd7..f4f826d2 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -6,7 +6,7 @@ import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; -import { formatDate } from "../lib/utils"; +import { formatDate, cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3 } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclip/shared"; @@ -161,6 +161,8 @@ export function IssuesList({ } return getViewState(viewStateKey); }); + const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); + const [assigneeSearch, setAssigneeSearch] = useState(""); const updateView = useCallback((patch: Partial) => { setViewState((prev) => { @@ -223,6 +225,12 @@ export function IssuesList({ return defaults; }; + const assignIssue = (issueId: string, assigneeAgentId: string | null) => { + onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); + setAssigneePickerIssueId(null); + setAssigneeSearch(""); + }; + return (

{/* Toolbar */} @@ -548,6 +556,84 @@ export function IssuesList({
)}
+ { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
{liveIssueIds?.has(issue.id) && ( @@ -557,12 +643,6 @@ export function IssuesList({ Live )} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : {issue.assigneeAgentId.slice(0, 8)}; - })()} {formatDate(issue.createdAt)} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 68ccff34..1372f55e 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { History, Search, SquarePen, + Network, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { SidebarSection } from "./SidebarSection"; @@ -90,6 +91,7 @@ export function Sidebar() { + diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b04122ef..2431f1fb 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -22,9 +22,23 @@ import { Identity } from "../components/Identity"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { ChevronRight, MoreHorizontal, EyeOff, Hexagon, Paperclip, Trash2, SlidersHorizontal } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Activity as ActivityIcon, + ChevronDown, + ChevronRight, + EyeOff, + Hexagon, + ListTree, + MessageSquare, + MoreHorizontal, + Paperclip, + SlidersHorizontal, + Trash2, +} from "lucide-react"; import type { ActivityEvent } from "@paperclip/shared"; import type { Agent, IssueAttachment } from "@paperclip/shared"; @@ -126,6 +140,12 @@ export function IssueDetail() { const navigate = useNavigate(); const [moreOpen, setMoreOpen] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); + const [detailTab, setDetailTab] = useState("comments"); + const [secondaryOpen, setSecondaryOpen] = useState({ + approvals: false, + runs: false, + cost: false, + }); const [attachmentError, setAttachmentError] = useState(null); const fileInputRef = useRef(null); @@ -505,10 +525,6 @@ export function IssueDetail() { />
- - - -

Attachments

@@ -583,24 +599,46 @@ export function IssueDetail() { - { - await addComment.mutateAsync({ body, reopen }); - }} - imageUploadHandler={async (file) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }} - /> + - {childIssues.length > 0 && ( - <> - -
-

Sub-issues

+ + + + + + + Comments + + + + Sub-issues + + + + Activity + + + + + { + await addComment.mutateAsync({ body, reopen }); + }} + imageUploadHandler={async (file) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + /> + + + + {childIssues.length === 0 ? ( +

No sub-issues.

+ ) : (
{childIssues.map((child) => ( ))}
-
- - )} + )} + + + + {!activity || activity.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {activity.slice(0, 20).map((evt) => ( +
+ + {formatAction(evt.action, evt.details)} + {relativeTime(evt.createdAt)} +
+ ))} +
+ )} +
+ {linkedApprovals && linkedApprovals.length > 0 && ( - <> - -
-

Linked Approvals

-
+ setSecondaryOpen((prev) => ({ ...prev, approvals: open }))} + className="rounded-lg border border-border" + > + + + Linked Approvals ({linkedApprovals.length}) + + + + +
{linkedApprovals.map((approval) => ( ))}
-
- + + )} - {/* Linked Runs */} {linkedRuns && linkedRuns.length > 0 && ( - <> - -
-

Linked Runs

-
+ setSecondaryOpen((prev) => ({ ...prev, runs: open }))} + className="rounded-lg border border-border" + > + + Linked Runs ({linkedRuns.length}) + + + +
{linkedRuns.map((run) => ( ))}
-
- + + )} - {/* Activity Log */} - {activity && activity.length > 0 && ( - <> - -
-

Activity

-
- {activity.slice(0, 20).map((evt) => ( -
- - {formatAction(evt.action, evt.details)} - {relativeTime(evt.createdAt)} + {linkedRuns && linkedRuns.length > 0 && ( + setSecondaryOpen((prev) => ({ ...prev, cost: open }))} + className="rounded-lg border border-border" + > + + Cost Summary + + + +
+ {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( +
No cost data yet.
+ ) : ( +
+ {issueCostSummary.hasCost && ( + + ${issueCostSummary.cost.toFixed(4)} + + )} + {issueCostSummary.hasTokens && ( + + Tokens {formatTokens(issueCostSummary.totalTokens)} + {issueCostSummary.cached > 0 + ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` + : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} + + )}
- ))} + )}
-
- - )} - - {(linkedRuns && linkedRuns.length > 0) && ( - <> - -
-

Cost

- {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( -
No cost data yet.
- ) : ( -
- {issueCostSummary.hasCost && ( - - ${issueCostSummary.cost.toFixed(4)} - - )} - {issueCostSummary.hasTokens && ( - - Tokens {formatTokens(issueCostSummary.totalTokens)} - {issueCostSummary.cached > 0 - ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` - : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} - - )} -
- )} -
- + + )} {/* Mobile properties drawer */} diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index c65e761a..b5715109 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -50,7 +50,7 @@ export function MyIssues() { key={issue.id} identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} - to={`/issues/${issue.identifier ?? issue.id}`}} + to={`/issues/${issue.identifier ?? issue.id}`} leading={ <> diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx new file mode 100644 index 00000000..ba42bbfc --- /dev/null +++ b/ui/src/pages/OrgChart.tsx @@ -0,0 +1,424 @@ +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { agentsApi, type OrgNode } from "../api/agents"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; +import { EmptyState } from "../components/EmptyState"; +import { AgentIcon } from "../components/AgentIconPicker"; +import { Network } from "lucide-react"; +import type { Agent } from "@paperclip/shared"; + +// Layout constants +const CARD_W = 200; +const CARD_H = 100; +const GAP_X = 32; +const GAP_Y = 80; +const PADDING = 60; + +// ── Tree layout types ─────────────────────────────────────────────────── + +interface LayoutNode { + id: string; + name: string; + role: string; + status: string; + x: number; + y: number; + children: LayoutNode[]; +} + +// ── Layout algorithm ──────────────────────────────────────────────────── + +/** Compute the width each subtree needs. */ +function subtreeWidth(node: OrgNode): number { + if (node.reports.length === 0) return CARD_W; + const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); + const gaps = (node.reports.length - 1) * GAP_X; + return Math.max(CARD_W, childrenW + gaps); +} + +/** Recursively assign x,y positions. */ +function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { + const totalW = subtreeWidth(node); + const layoutChildren: LayoutNode[] = []; + + if (node.reports.length > 0) { + const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); + const gaps = (node.reports.length - 1) * GAP_X; + let cx = x + (totalW - childrenW - gaps) / 2; + + for (const child of node.reports) { + const cw = subtreeWidth(child); + layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y)); + cx += cw + GAP_X; + } + } + + return { + id: node.id, + name: node.name, + role: node.role, + status: node.status, + x: x + (totalW - CARD_W) / 2, + y, + children: layoutChildren, + }; +} + +/** Layout all root nodes side by side. */ +function layoutForest(roots: OrgNode[]): LayoutNode[] { + if (roots.length === 0) return []; + + const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0); + const gaps = (roots.length - 1) * GAP_X; + let x = PADDING; + const y = PADDING; + + const result: LayoutNode[] = []; + for (const root of roots) { + const w = subtreeWidth(root); + result.push(layoutTree(root, x, y)); + x += w + GAP_X; + } + + // Compute bounds and return + return result; +} + +/** Flatten layout tree to list of nodes. */ +function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { + const result: LayoutNode[] = []; + function walk(n: LayoutNode) { + result.push(n); + n.children.forEach(walk); + } + nodes.forEach(walk); + return result; +} + +/** Collect all parent→child edges. */ +function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> { + const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = []; + function walk(n: LayoutNode) { + for (const c of n.children) { + edges.push({ parent: n, child: c }); + walk(c); + } + } + nodes.forEach(walk); + return edges; +} + +// ── Status dot colors (raw hex for SVG) ───────────────────────────────── + +const adapterLabels: Record = { + claude_local: "Claude", + codex_local: "Codex", + process: "Process", + http: "HTTP", +}; + +const statusDotColor: Record = { + running: "#22d3ee", + active: "#4ade80", + paused: "#facc15", + idle: "#facc15", + error: "#f87171", + terminated: "#a3a3a3", +}; +const defaultDotColor = "#a3a3a3"; + +// ── Main component ────────────────────────────────────────────────────── + +export function OrgChart() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const navigate = useNavigate(); + + const { data: orgTree, isLoading } = useQuery({ + queryKey: queryKeys.org(selectedCompanyId!), + queryFn: () => agentsApi.org(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const agentMap = useMemo(() => { + const m = new Map(); + for (const a of agents ?? []) m.set(a.id, a); + return m; + }, [agents]); + + useEffect(() => { + setBreadcrumbs([{ label: "Org Chart" }]); + }, [setBreadcrumbs]); + + // Layout computation + const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]); + const allNodes = useMemo(() => flattenLayout(layout), [layout]); + const edges = useMemo(() => collectEdges(layout), [layout]); + + // Compute SVG bounds + const bounds = useMemo(() => { + if (allNodes.length === 0) return { width: 800, height: 600 }; + let maxX = 0, maxY = 0; + for (const n of allNodes) { + maxX = Math.max(maxX, n.x + CARD_W); + maxY = Math.max(maxY, n.y + CARD_H); + } + return { width: maxX + PADDING, height: maxY + PADDING }; + }, [allNodes]); + + // Pan & zoom state + const containerRef = useRef(null); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [dragging, setDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + + // Center the chart on first load + const hasInitialized = useRef(false); + useEffect(() => { + if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; + hasInitialized.current = true; + + const container = containerRef.current; + const containerW = container.clientWidth; + const containerH = container.clientHeight; + + // Fit chart to container + const scaleX = (containerW - 40) / bounds.width; + const scaleY = (containerH - 40) / bounds.height; + const fitZoom = Math.min(scaleX, scaleY, 1); + + const chartW = bounds.width * fitZoom; + const chartH = bounds.height * fitZoom; + + setZoom(fitZoom); + setPan({ + x: (containerW - chartW) / 2, + y: (containerH - chartH) / 2, + }); + }, [allNodes, bounds]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + // Don't drag if clicking a card + const target = e.target as HTMLElement; + if (target.closest("[data-org-card]")) return; + setDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; + }, [pan]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragging) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy }); + }, [dragging]); + + const handleMouseUp = useCallback(() => { + setDragging(false); + }, []); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const factor = e.deltaY < 0 ? 1.1 : 0.9; + const newZoom = Math.min(Math.max(zoom * factor, 0.2), 2); + + // Zoom toward mouse position + const scale = newZoom / zoom; + setPan({ + x: mouseX - scale * (mouseX - pan.x), + y: mouseY - scale * (mouseY - pan.y), + }); + setZoom(newZoom); + }, [zoom, pan]); + + if (!selectedCompanyId) { + return ; + } + + if (isLoading) { + return

Loading...

; + } + + if (orgTree && orgTree.length === 0) { + return ; + } + + return ( +
+ {/* Zoom controls */} +
+ + + +
+ + {/* SVG layer for edges */} + + + {edges.map(({ parent, child }) => { + const x1 = parent.x + CARD_W / 2; + const y1 = parent.y + CARD_H; + const x2 = child.x + CARD_W / 2; + const y2 = child.y; + const midY = (y1 + y2) / 2; + + return ( + + ); + })} + + + + {/* Card layer */} +
+ {allNodes.map((node) => { + const agent = agentMap.get(node.id); + const dotColor = statusDotColor[node.status] ?? defaultDotColor; + + return ( +
navigate(`/agents/${node.id}`)} + > +
+ {/* Agent icon + status dot */} +
+
+ +
+ +
+ {/* Name + role + adapter type */} +
+ + {node.name} + + + {agent?.title ?? roleLabel(node.role)} + + {agent && ( + + {adapterLabels[agent.adapterType] ?? agent.adapterType} + + )} +
+
+
+ ); + })} +
+
+ ); +} + +const roleLabels: Record = { + ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", + engineer: "Engineer", designer: "Designer", pm: "PM", + qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", +}; + +function roleLabel(role: string): string { + return roleLabels[role] ?? role; +}