/
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 */}
+
+
+ {/* 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;
+}