diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7319f24a..36633a8e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,8 @@ -import { Routes, Route, Navigate } from "react-router-dom"; +import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Layout } from "./components/Layout"; +import { authApi } from "./api/auth"; +import { healthApi } from "./api/health"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; @@ -17,43 +20,113 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { AuthPage } from "./pages/Auth"; +import { InviteLandingPage } from "./pages/InviteLanding"; +import { queryKeys } from "./lib/queryKeys"; + +function BootstrapPendingPage() { + return ( +
+
+

Instance setup required

+

+ No instance admin exists yet. Run this command in your Paperclip environment to generate + the first admin invite URL: +

+
+{`pnpm paperclip auth bootstrap-ceo`}
+        
+
+
+ ); +} + +function CloudAccessGate() { + const location = useLocation(); + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + }); + + const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: isAuthenticatedMode, + retry: false, + }); + + if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { + return
Loading...
; + } + + if (healthQuery.error) { + return ( +
+ {healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"} +
+ ); + } + + if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { + return ; + } + + if (isAuthenticatedMode && !sessionQuery.data) { + const next = encodeURIComponent(`${location.pathname}${location.search}`); + return ; + } + + return ; +} export function App() { return ( - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts new file mode 100644 index 00000000..25bfe138 --- /dev/null +++ b/ui/src/api/access.ts @@ -0,0 +1,52 @@ +import type { JoinRequest } from "@paperclip/shared"; +import { api } from "./client"; + +type InviteSummary = { + id: string; + companyId: string | null; + inviteType: "company_join" | "bootstrap_ceo"; + allowedJoinTypes: "human" | "agent" | "both"; + expiresAt: string; +}; + +type AcceptInviteInput = + | { requestType: "human" } + | { + requestType: "agent"; + agentName: string; + adapterType?: string; + capabilities?: string | null; + agentDefaultsPayload?: Record | null; + }; + +export const accessApi = { + createCompanyInvite: ( + companyId: string, + input: { + allowedJoinTypes?: "human" | "agent" | "both"; + expiresInHours?: number; + defaultsPayload?: Record | null; + } = {}, + ) => + api.post<{ + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + }>(`/companies/${companyId}/invites`, input), + + getInvite: (token: string) => api.get(`/invites/${token}`), + + acceptInvite: (token: string, input: AcceptInviteInput) => + api.post(`/invites/${token}/accept`, input), + + listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") => + api.get(`/companies/${companyId}/join-requests?status=${status}`), + + approveJoinRequest: (companyId: string, requestId: string) => + api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), + + rejectJoinRequest: (companyId: string, requestId: string) => + api.post(`/companies/${companyId}/join-requests/${requestId}/reject`, {}), +}; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index f19a49ab..fbeef4a4 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -22,6 +22,15 @@ export interface AdapterModel { label: string; } +export interface ClaudeLoginResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + loginUrl: string | null; + stdout: string; + stderr: string; +} + export interface OrgNode { id: string; name: string; @@ -87,4 +96,5 @@ export const agentsApi = { idempotencyKey?: string | null; }, ) => api.post(`/agents/${id}/wakeup`, data), + loginWithClaude: (id: string) => api.post(`/agents/${id}/claude-login`, {}), }; diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts new file mode 100644 index 00000000..5aa5b69a --- /dev/null +++ b/ui/src/api/auth.ts @@ -0,0 +1,74 @@ +export type AuthSession = { + session: { id: string; userId: string }; + user: { id: string; email: string | null; name: string | null }; +}; + +function toSession(value: unknown): AuthSession | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const sessionValue = record.session; + const userValue = record.user; + if (!sessionValue || typeof sessionValue !== "object") return null; + if (!userValue || typeof userValue !== "object") return null; + const session = sessionValue as Record; + const user = userValue as Record; + if (typeof session.id !== "string" || typeof session.userId !== "string") return null; + if (typeof user.id !== "string") return null; + return { + session: { id: session.id, userId: session.userId }, + user: { + id: user.id, + email: typeof user.email === "string" ? user.email : null, + name: typeof user.name === "string" ? user.name : null, + }, + }; +} + +async function authPost(path: string, body: Record) { + const res = await fetch(`/api/auth${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + const message = + (payload as { error?: { message?: string } | string } | null)?.error && + typeof (payload as { error?: { message?: string } | string }).error === "object" + ? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`) + : (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`; + throw new Error(message); + } + return payload; +} + +export const authApi = { + getSession: async (): Promise => { + const res = await fetch("/api/auth/get-session", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (res.status === 401) return null; + const payload = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(`Failed to load session (${res.status})`); + } + const direct = toSession(payload); + if (direct) return direct; + const nested = payload && typeof payload === "object" ? toSession((payload as Record).data) : null; + return nested; + }, + + signInEmail: async (input: { email: string; password: string }) => { + await authPost("/sign-in/email", input); + }, + + signUpEmail: async (input: { name: string; email: string; password: string }) => { + await authPost("/sign-up/email", input); + }, + + signOut: async () => { + await authPost("/sign-out", {}); + }, +}; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 2841e7e5..b1b4f648 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,5 +1,17 @@ const BASE = "/api"; +export class ApiError extends Error { + status: number; + body: unknown; + + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + async function request(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers ?? undefined); const body = init?.body; @@ -9,11 +21,16 @@ async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { headers, + credentials: "include", ...init, }); if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.error ?? `Request failed: ${res.status}`); + const errorBody = await res.json().catch(() => null); + throw new ApiError( + (errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`, + res.status, + errorBody, + ); } return res.json(); } diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts new file mode 100644 index 00000000..272896d3 --- /dev/null +++ b/ui/src/api/health.ts @@ -0,0 +1,20 @@ +export type HealthStatus = { + status: "ok"; + deploymentMode?: "local_trusted" | "authenticated"; + deploymentExposure?: "private" | "public"; + authReady?: boolean; + bootstrapStatus?: "ready" | "bootstrap_pending"; +}; + +export const healthApi = { + get: async (): Promise => { + const res = await fetch("/api/health", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`Failed to load health (${res.status})`); + } + return res.json(); + }, +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 43be4f5b..8c95c246 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,4 +1,7 @@ export { api } from "./client"; +export { authApi } from "./auth"; +export { healthApi } from "./health"; +export { accessApi } from "./access"; export { companiesApi } from "./companies"; export { agentsApi } from "./agents"; export { projectsApi } from "./projects"; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index e0439aec..d8e41ac4 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -2,7 +2,12 @@ import type { Approval, Issue, IssueAttachment, IssueComment } from "@paperclip/ import { api } from "./client"; export const issuesApi = { - list: (companyId: string) => api.get(`/companies/${companyId}/issues`), + list: (companyId: string, filters?: { projectId?: string }) => { + const params = new URLSearchParams(); + if (filters?.projectId) params.set("projectId", filters.projectId); + const qs = params.toString(); + return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); + }, get: (id: string) => api.get(`/issues/${id}`), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index c5705306..68fcc9ca 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { Identity } from "./Identity"; import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; @@ -85,8 +85,6 @@ interface ActivityRowProps { } export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) { - const navigate = useNavigate(); - const verb = formatVerb(event.action, event.details); const isHeartbeatEvent = event.entityType === "heartbeat_run"; @@ -104,27 +102,38 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; + const inner = ( +
+

+ + {verb} + {name && {name}} +

+ {timeAgo(event.createdAt)} +
+ ); + + const classes = cn( + "px-4 py-2 text-sm", + link && "cursor-pointer hover:bg-accent/50 transition-colors", + className, + ); + + if (link) { + return ( + + {inner} + + ); + } + return ( -
navigate(link) : undefined} - > -
-

- - {verb} - {name && {name}} -

- {timeAgo(event.createdAt)} -
+
+ {inner}
); } diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index f281a570..35c50055 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -50,6 +50,8 @@ type AgentConfigFormProps = { onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; hideInlineSave?: boolean; + /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ + sectionLayout?: "inline" | "cards"; } & ( | { mode: "create"; @@ -138,6 +140,7 @@ function extractPickedDirectoryPath(handle: unknown): string | null { export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; + const cards = props.sectionLayout === "cards"; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -324,7 +327,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : false; return ( -
+
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {isDirty && !props.hideInlineSave && (
@@ -343,9 +346,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {/* ---- Identity (edit only) ---- */} {!isCreate && ( -
-
Identity
-
+
+ {cards + ?

Identity

+ :
Identity
+ } +
-
- - Adapter - +
+
+ {cards + ?

Adapter

+ : Adapter + }
-
+
-
- Permissions & Configuration -
-
+
+ {cards + ?

Permissions & Configuration

+ :
Permissions & Configuration
+ } +
-
- - Run Policy -
-
+
+ {cards + ?

Run Policy

+ :
Run Policy
+ } +
) : ( -
-
- - Run Policy -
-
- mark("heartbeat", "enabled", v)} - number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} - onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} - numberLabel="sec" - numberPrefix="Run heartbeat every" - numberHint={help.intervalSec} - showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} - /> -
- setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} - > +
+ {cards + ?

Run Policy

+ :
Run Policy
+ } +
+
+ mark("heartbeat", "enabled", v)} + number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} + onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} + numberLabel="sec" + numberPrefix="Run heartbeat every" + numberHint={help.intervalSec} + showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} + /> +
+ setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} + >
+
)} diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 72b710fa..445e93ac 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -1,4 +1,5 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react"; +import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Identity } from "./Identity"; import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; @@ -19,13 +20,15 @@ export function ApprovalCard({ onApprove, onReject, onOpen, + detailLink, isPending, }: { approval: Approval; requesterAgent: Agent | null; onApprove: () => void; onReject: () => void; - onOpen: () => void; + onOpen?: () => void; + detailLink?: string; isPending: boolean; }) { const Icon = typeIcon[approval.type] ?? defaultTypeIcon; @@ -85,9 +88,15 @@ export function ApprovalCard({
)}
- + {detailLink ? ( + + ) : ( + + )}
); diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index 7f837678..55438b2c 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -33,7 +33,7 @@ export function BreadcrumbBar() { // Single breadcrumb = page title (uppercase) if (breadcrumbs.length === 1) { return ( -
+
{menuButton}

{breadcrumbs[0].label} @@ -44,7 +44,7 @@ export function BreadcrumbBar() { // Multiple breadcrumbs = breadcrumb trail return ( -
+
{menuButton} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index fe463b7d..243e01fd 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,9 +1,9 @@ import { useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import Markdown from "react-markdown"; import type { IssueComment, Agent } from "@paperclip/shared"; import { Button } from "@/components/ui/button"; import { Identity } from "./Identity"; +import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { formatDateTime } from "../lib/utils"; @@ -17,11 +17,12 @@ interface CommentThreadProps { onAdd: (body: string, reopen?: boolean) => Promise; issueStatus?: string; agentMap?: Map; + imageUploadHandler?: (file: File) => Promise; } const CLOSED_STATUSES = new Set(["done", "cancelled"]); -export function CommentThread({ comments, onAdd, issueStatus, agentMap }: CommentThreadProps) { +export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -35,13 +36,15 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen [comments], ); - // Build mention options from agent map + // Build mention options from agent map (exclude terminated agents) const mentions = useMemo(() => { if (!agentMap) return []; - return Array.from(agentMap.values()).map((a) => ({ - id: a.id, - name: a.name, - })); + return Array.from(agentMap.values()) + .filter((a) => a.status !== "terminated") + .map((a) => ({ + id: a.id, + name: a.name, + })); }, [agentMap]); async function handleSubmit() { @@ -84,9 +87,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen {formatDateTime(comment.createdAt)}
-
- {comment.body} -
+ {comment.body} {comment.runId && comment.runAgentId && (
diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx new file mode 100644 index 00000000..8aaec808 --- /dev/null +++ b/ui/src/components/CompanyRail.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Paperclip, Plus } from "lucide-react"; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; +import { cn } from "../lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { Company } from "@paperclip/shared"; + +const COMPANY_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#ec4899", // pink + "#f43f5e", // rose + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#14b8a6", // teal + "#06b6d4", // cyan + "#3b82f6", // blue +]; + +const ORDER_STORAGE_KEY = "paperclip.companyOrder"; + +function companyColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length]!; +} + +function getStoredOrder(): string[] { + try { + const raw = localStorage.getItem(ORDER_STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch { /* ignore */ } + return []; +} + +function saveOrder(ids: string[]) { + localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)); +} + +/** Sort companies by stored order, appending any new ones at the end. */ +function sortByStoredOrder(companies: Company[]): Company[] { + const order = getStoredOrder(); + if (order.length === 0) return companies; + + const byId = new Map(companies.map((c) => [c.id, c])); + const sorted: Company[] = []; + + for (const id of order) { + const c = byId.get(id); + if (c) { + sorted.push(c); + byId.delete(id); + } + } + // Append any companies not in stored order + for (const c of byId.values()) { + sorted.push(c); + } + return sorted; +} + +function SortableCompanyItem({ + company, + isSelected, + onSelect, +}: { + company: Company; + isSelected: boolean; + onSelect: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: company.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : undefined, + opacity: isDragging ? 0.8 : 1, + }; + + const color = companyColor(company.name); + const initial = company.name.charAt(0).toUpperCase(); + + return ( +
+ + + + + +

{company.name}

+
+
+
+ ); +} + +export function CompanyRail() { + const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { openOnboarding } = useDialog(); + + // Maintain sorted order in local state, synced from companies + localStorage + const [orderedIds, setOrderedIds] = useState(() => + sortByStoredOrder(companies).map((c) => c.id) + ); + + // Sync order across tabs via the native storage event + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key !== ORDER_STORAGE_KEY) return; + try { + const ids: string[] = e.newValue ? JSON.parse(e.newValue) : []; + setOrderedIds(ids); + } catch { /* ignore malformed data */ } + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + // Re-derive when companies change (new company added/removed) + const orderedCompanies = useMemo(() => { + const byId = new Map(companies.map((c) => [c.id, c])); + const result: Company[] = []; + for (const id of orderedIds) { + const c = byId.get(id); + if (c) { + result.push(c); + byId.delete(id); + } + } + // Append any new companies not yet in our order + for (const c of byId.values()) { + result.push(c); + } + return result; + }, [companies, orderedIds]); + + // Require 8px of movement before starting a drag to avoid interfering with clicks + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const ids = orderedCompanies.map((c) => c.id); + const oldIndex = ids.indexOf(active.id as string); + const newIndex = ids.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + const newIds = arrayMove(ids, oldIndex, newIndex); + setOrderedIds(newIds); + saveOrder(newIds); + }, + [orderedCompanies] + ); + + return ( +
+ {/* Paperclip icon - aligned with top sections (implied line, no visible border) */} +
+ +
+ + {/* Company list */} +
+ + c.id)} + strategy={verticalListSortingStrategy} + > + {orderedCompanies.map((company) => ( + setSelectedCompanyId(company.id)} + /> + ))} + + +
+ + {/* Separator before add button */} +
+ + {/* Add company button */} +
+ + + + + +

Add company

+
+
+
+
+ ); +} diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 956bc70e..a5aeb9a5 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -1,4 +1,5 @@ import { type ReactNode } from "react"; +import { Link } from "react-router-dom"; import { cn } from "../lib/utils"; interface EntityRowProps { @@ -8,6 +9,7 @@ interface EntityRowProps { subtitle?: string; trailing?: ReactNode; selected?: boolean; + to?: string; onClick?: () => void; className?: string; } @@ -19,19 +21,20 @@ export function EntityRow({ subtitle, trailing, selected, + to, onClick, className, }: EntityRowProps) { - return ( -
+ const isClickable = !!(to || onClick); + const classes = cn( + "flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors", + isClickable && "cursor-pointer hover:bg-accent/50", + selected && "bg-accent/30", + className + ); + + const content = ( + <> {leading &&
{leading}
}
@@ -47,6 +50,20 @@ export function EntityRow({ )}
{trailing &&
{trailing}
} + + ); + + if (to) { + return ( + + {content} + + ); + } + + return ( +
+ {content}
); } diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx index 920ccf75..88f86ebd 100644 --- a/ui/src/components/GoalTree.tsx +++ b/ui/src/components/GoalTree.tsx @@ -1,4 +1,5 @@ import type { Goal } from "@paperclip/shared"; +import { Link } from "react-router-dom"; import { StatusBadge } from "./StatusBadge"; import { ChevronRight } from "lucide-react"; import { cn } from "../lib/utils"; @@ -6,6 +7,7 @@ import { useState } from "react"; interface GoalTreeProps { goals: Goal[]; + goalLink?: (goal: Goal) => string; onSelect?: (goal: Goal) => void; } @@ -14,41 +16,62 @@ interface GoalNodeProps { children: Goal[]; allGoals: Goal[]; depth: number; + goalLink?: (goal: Goal) => string; onSelect?: (goal: Goal) => void; } -function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) { +function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalNodeProps) { const [expanded, setExpanded] = useState(true); const hasChildren = children.length > 0; + const link = goalLink?.(goal); + + const inner = ( + <> + {hasChildren ? ( + + ) : ( + + )} + {goal.level} + {goal.title} + + + ); + + const classes = cn( + "flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50", + ); return (
-
onSelect?.(goal)} - > - {hasChildren ? ( - - ) : ( - - )} - {goal.level} - {goal.title} - -
+ {link ? ( + + {inner} + + ) : ( +
onSelect?.(goal)} + > + {inner} +
+ )} {hasChildren && expanded && (
{children.map((child) => ( @@ -58,6 +81,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) children={allGoals.filter((g) => g.parentId === child.id)} allGoals={allGoals} depth={depth + 1} + goalLink={goalLink} onSelect={onSelect} /> ))} @@ -67,7 +91,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) ); } -export function GoalTree({ goals, onSelect }: GoalTreeProps) { +export function GoalTree({ goals, goalLink, onSelect }: GoalTreeProps) { const roots = goals.filter((g) => !g.parentId); if (goals.length === 0) { @@ -83,6 +107,7 @@ export function GoalTree({ goals, onSelect }: GoalTreeProps) { children={goals.filter((g) => g.parentId === goal.id)} allGoals={goals} depth={0} + goalLink={goalLink} onSelect={onSelect} /> ))} diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 48075af1..cfcf6944 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; -import Markdown from "react-markdown"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; +import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor } from "./MarkdownEditor"; interface InlineEditorProps { @@ -138,9 +138,7 @@ export function InlineEditor({ onClick={() => setEditing(true)} > {value && multiline ? ( -
- {value} -
+ {value} ) : ( value || placeholder )} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 4f5e931a..ee40b1e6 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -14,6 +14,7 @@ import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { User, Hexagon, ArrowUpRight } from "lucide-react"; +import { AgentIcon } from "./AgentIconPicker"; interface IssuePropertiesProps { issue: Issue; @@ -130,6 +131,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { )} onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }} > + {a.name} ))} @@ -151,7 +153,13 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { - + ))} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index fd22ac4b..0a465155 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useDialog } from "../context/DialogContext"; import { groupBy } from "../lib/groupBy"; import { formatDate } from "../lib/utils"; @@ -139,7 +139,6 @@ export function IssuesList({ onUpdateIssue, }: IssuesListProps) { const { openNewIssue } = useDialog(); - const navigate = useNavigate(); const [viewState, setViewState] = useState(() => getViewState(viewStateKey)); @@ -202,7 +201,7 @@ export function IssuesList({
{/* Toolbar */}
- @@ -434,16 +433,14 @@ export function IssuesList({ )} {group.items.map((issue) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
navigate(`/issues/${issue.identifier ?? issue.id}`)} + to={`/issues/${issue.identifier ?? issue.id}`} + className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit" > {/* Spacer matching caret width so status icon aligns with group title */}
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
e.stopPropagation()}> +
{ e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} @@ -473,7 +470,7 @@ export function IssuesList({ {formatDate(issue.createdAt)}
-
+ ))} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 4bae4201..19e7e5cb 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { BookOpen } from "lucide-react"; import { Outlet } from "react-router-dom"; +import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; +import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; @@ -15,31 +19,50 @@ import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; +import { healthApi } from "../api/health"; +import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { panelContent, closePanel } = usePanel(); - const { companies, loading: companiesLoading } = useCompany(); + const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany(); const onboardingTriggered = useRef(false); + const { data: health } = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + }); useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; + if (health?.deploymentMode === "authenticated") return; if (companies.length === 0) { onboardingTriggered.current = true; openOnboarding(); } - }, [companies, companiesLoading, openOnboarding]); + }, [companies, companiesLoading, openOnboarding, health?.deploymentMode]); const togglePanel = useCallback(() => { if (panelContent) closePanel(); }, [panelContent, closePanel]); + // Cmd+1..9 to switch companies + const switchCompany = useCallback( + (index: number) => { + if (index < companies.length) { + setSelectedCompanyId(companies[index]!.id); + } + }, + [companies, setSelectedCompanyId], + ); + useKeyboardShortcuts({ onNewIssue: () => openNewIssue(), onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, + onSwitchCompany: switchCompany, }); return ( @@ -52,24 +75,40 @@ export function Layout() { /> )} - {/* Sidebar */} + {/* Combined sidebar area: company rail + inner sidebar + docs bar */} {isMobile ? (
- +
+
+ + +
+
+ +
+
) : ( -
- +
+
+ +
+ +
+
+
+ +
)} diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx new file mode 100644 index 00000000..6521077b --- /dev/null +++ b/ui/src/components/MarkdownBody.tsx @@ -0,0 +1,21 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { cn } from "../lib/utils"; + +interface MarkdownBodyProps { + children: string; + className?: string; +} + +export function MarkdownBody({ children, className }: MarkdownBodyProps) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index 798ad197..913cc926 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; import { Card, CardContent } from "@/components/ui/card"; interface MetricCardProps { @@ -7,25 +8,22 @@ interface MetricCardProps { value: string | number; label: string; description?: ReactNode; + to?: string; onClick?: () => void; } -export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) { - return ( +export function MetricCard({ icon: Icon, value, label, description, to, onClick }: MetricCardProps) { + const isClickable = !!(to || onClick); + + const inner = (
-

+

{value}

-

+

{label}

{description && ( @@ -39,4 +37,22 @@ export function MetricCard({ icon: Icon, value, label, description, onClick }: M ); + + if (to) { + return ( + + {inner} + + ); + } + + if (onClick) { + return ( +
+ {inner} +
+ ); + } + + return inner; } diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 82f7cd9b..d26695fd 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -27,6 +27,7 @@ import { roleLabels } from "./agent-config-primitives"; import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; +import { AgentIcon } from "./AgentIconPicker"; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent } = useDialog(); @@ -163,9 +164,9 @@ export function NewAgentDialog() {
{/* Name */} -
+
setName(e.target.value)} @@ -225,13 +226,17 @@ export function NewAgentDialog() { )} disabled={isFirstAgent} > - - {currentReportsTo - ? `Reports to ${currentReportsTo.name}` - : isFirstAgent - ? "Reports to: N/A (CEO)" - : "Reports to..." - } + {currentReportsTo ? ( + <> + + {`Reports to ${currentReportsTo.name}`} + + ) : ( + <> + + {isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."} + + )} @@ -253,6 +258,7 @@ export function NewAgentDialog() { )} onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }} > + {a.name} {roleLabels[a.role] ?? a.role} diff --git a/ui/src/components/NewGoalDialog.tsx b/ui/src/components/NewGoalDialog.tsx index 97338c64..2dc5453d 100644 --- a/ui/src/components/NewGoalDialog.tsx +++ b/ui/src/components/NewGoalDialog.tsx @@ -151,9 +151,9 @@ export function NewGoalDialog() {
{/* Title */} -
+
setTitle(e.target.value)} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index bcd58a1d..1aead1e9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -34,6 +34,7 @@ import { } from "lucide-react"; import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; +import { AgentIcon } from "./AgentIconPicker"; import type { Project, Agent } from "@paperclip/shared"; const DRAFT_KEY = "paperclip:issue-draft"; @@ -373,8 +374,17 @@ export function NewIssueDialog() { { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}> @@ -410,6 +420,7 @@ export function NewIssueDialog() { )} onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }} > + {a.name} ))} @@ -420,14 +431,26 @@ export function NewIssueDialog() { - + ))} diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 1e6c1065..399af6f8 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -24,6 +24,7 @@ import { Plus, X, } from "lucide-react"; +import { PROJECT_COLORS } from "@paperclip/shared"; import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; @@ -89,6 +90,7 @@ export function NewProjectDialog() { name: name.trim(), description: description.trim() || undefined, status, + color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)], ...(goalIds.length > 0 ? { goalIds } : {}), ...(targetDate ? { targetDate } : {}), }); @@ -151,9 +153,9 @@ export function NewProjectDialog() {
{/* Name */} -
+
setName(e.target.value)} diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index dc183d3b..bd585961 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { agentsApi } from "../api/agents"; +import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { AgentIcon } from "./AgentIconPicker"; @@ -15,6 +16,27 @@ import { } from "@/components/ui/collapsible"; import type { Agent } from "@paperclip/shared"; +/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */ +function sortByHierarchy(agents: Agent[]): Agent[] { + const byId = new Map(agents.map((a) => [a.id, a])); + const childrenOf = new Map(); + for (const a of agents) { + const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null; + const list = childrenOf.get(parent) ?? []; + list.push(a); + childrenOf.set(parent, list); + } + const sorted: Agent[] = []; + const queue = childrenOf.get(null) ?? []; + while (queue.length > 0) { + const agent = queue.shift()!; + sorted.push(agent); + const children = childrenOf.get(agent.id); + if (children) queue.push(...children); + } + return sorted; +} + export function SidebarAgents() { const [open, setOpen] = useState(true); const { selectedCompanyId } = useCompany(); @@ -27,9 +49,27 @@ export function SidebarAgents() { enabled: !!selectedCompanyId, }); - const visibleAgents = (agents ?? []).filter( - (a: Agent) => a.status !== "terminated" - ); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 10_000, + }); + + const liveCountByAgent = useMemo(() => { + const counts = new Map(); + for (const run of liveRuns ?? []) { + counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1); + } + return counts; + }, [liveRuns]); + + const visibleAgents = useMemo(() => { + const filtered = (agents ?? []).filter( + (a: Agent) => a.status !== "terminated" + ); + return sortByHierarchy(filtered); + }, [agents]); const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/); const activeAgentId = agentMatch?.[1] ?? null; @@ -54,24 +94,38 @@ export function SidebarAgents() {
- {visibleAgents.map((agent: Agent) => ( - { - if (isMobile) setSidebarOpen(false); - }} - className={cn( - "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeAgentId === agent.id - ? "bg-accent text-foreground" - : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" - )} - > - - {agent.name} - - ))} + {visibleAgents.map((agent: Agent) => { + const runCount = liveCountByAgent.get(agent.id) ?? 0; + return ( + { + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", + activeAgentId === agent.id + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" + )} + > + + {agent.name} + {runCount > 0 && ( + + + + + + + {runCount} live + + + )} + + ); + })}
diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index 399ed659..4749f749 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -11,6 +11,7 @@ interface SidebarNavItemProps { badge?: number; badgeTone?: "default" | "danger"; alert?: boolean; + liveCount?: number; } export function SidebarNavItem({ @@ -21,6 +22,7 @@ export function SidebarNavItem({ badge, badgeTone = "default", alert = false, + liveCount, }: SidebarNavItemProps) { const { isMobile, setSidebarOpen } = useSidebar(); @@ -45,6 +47,15 @@ export function SidebarNavItem({ )} {label} + {liveCount != null && liveCount > 0 && ( + + + + + + {liveCount} live + + )} {badge != null && badge > 0 && ( ( { if (isMobile) setSidebarOpen(false); }} diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 061a2db0..f4ad4758 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -10,6 +10,7 @@ import { import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { Company } from "@paperclip/shared"; import { companiesApi } from "../api/companies"; +import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; interface CompanyContextValue { @@ -39,7 +40,17 @@ export function CompanyProvider({ children }: { children: ReactNode }) { const { data: companies = [], isLoading, error } = useQuery({ queryKey: queryKeys.companies.all, - queryFn: () => companiesApi.list(), + queryFn: async () => { + try { + return await companiesApi.list(); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + return []; + } + throw err; + } + }, + retry: false, }); // Auto-select first company when list loads diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 9c8fa1fc..19bd0fbc 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -75,16 +75,49 @@ interface IssueToastContext { href: string; } +function resolveIssueQueryRefs( + queryClient: QueryClient, + companyId: string, + issueId: string, + details: Record | null, +): string[] { + const refs = new Set([issueId]); + const detailIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId)); + const listIssues = queryClient.getQueryData(queryKeys.issues.list(companyId)); + const detailsIdentifier = + readString(details?.identifier) ?? + readString(details?.issueIdentifier); + + if (detailsIdentifier) refs.add(detailsIdentifier); + + if (detailIssue?.id) refs.add(detailIssue.id); + if (detailIssue?.identifier) refs.add(detailIssue.identifier); + + const listIssue = listIssues?.find((issue) => { + if (issue.id === issueId) return true; + if (issue.identifier && issue.identifier === issueId) return true; + if (detailsIdentifier && issue.identifier === detailsIdentifier) return true; + return false; + }); + if (listIssue?.id) refs.add(listIssue.id); + if (listIssue?.identifier) refs.add(listIssue.identifier); + + return Array.from(refs); +} + function resolveIssueToastContext( queryClient: QueryClient, companyId: string, issueId: string, details: Record | null, ): IssueToastContext { - const detailIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId)); + const issueRefs = resolveIssueQueryRefs(queryClient, companyId, issueId, details); + const detailIssue = issueRefs + .map((ref) => queryClient.getQueryData(queryKeys.issues.detail(ref))) + .find((issue): issue is Issue => !!issue); const listIssue = queryClient .getQueryData(queryKeys.issues.list(companyId)) - ?.find((issue) => issue.id === issueId); + ?.find((issue) => issueRefs.some((ref) => issue.id === ref || issue.identifier === ref)); const cachedIssue = detailIssue ?? listIssue ?? null; const ref = readString(details?.identifier) ?? @@ -290,12 +323,16 @@ function invalidateActivityQueries( if (entityType === "issue") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); if (entityId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) }); + const details = readRecord(payload.details); + const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details); + for (const ref of issueRefs) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) }); + } } return; } diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index 6120da80..f12c9f3e 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -4,9 +4,10 @@ interface ShortcutHandlers { onNewIssue?: () => void; onToggleSidebar?: () => void; onTogglePanel?: () => void; + onSwitchCompany?: (index: number) => void; } -export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) { +export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) { useEffect(() => { function handleKeyDown(e: KeyboardEvent) { // Don't fire shortcuts when typing in inputs @@ -15,6 +16,13 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane return; } + // Cmd+1..9 โ†’ Switch company + if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") { + e.preventDefault(); + onSwitchCompany?.(parseInt(e.key, 10) - 1); + return; + } + // C โ†’ New Issue if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); @@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onNewIssue, onToggleSidebar, onTogglePanel]); + }, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]); } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 5a8f7742..0b16f2e1 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -14,6 +14,8 @@ export const queryKeys = { }, issues: { list: (companyId: string) => ["issues", companyId] as const, + listByProject: (companyId: string, projectId: string) => + ["issues", companyId, "project", projectId] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, @@ -38,6 +40,15 @@ export const queryKeys = { comments: (approvalId: string) => ["approvals", "comments", approvalId] as const, issues: (approvalId: string) => ["approvals", "issues", approvalId] as const, }, + access: { + joinRequests: (companyId: string, status: string = "pending_approval") => + ["access", "join-requests", companyId, status] as const, + invite: (token: string) => ["access", "invite", token] as const, + }, + auth: { + session: ["auth", "session"] as const, + }, + health: ["health"] as const, secrets: { list: (companyId: string) => ["secrets", companyId] as const, providers: (companyId: string) => ["secret-providers", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 3b1b5d0d..da2cf48a 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -12,7 +12,6 @@ import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { AgentConfigForm } from "../components/AgentConfigForm"; -import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { getUIAdapter, buildTranscript } from "../adapters"; import type { TranscriptEntry } from "../adapters"; @@ -22,9 +21,7 @@ import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; -import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, @@ -48,7 +45,9 @@ import { EyeOff, Copy, ChevronRight, + ChevronDown, ArrowLeft, + Settings, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; @@ -167,14 +166,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailTab = "overview" | "configuration" | "runs" | "issues" | "costs" | "keys"; +type AgentDetailView = "overview" | "configure" | "runs"; -function parseAgentDetailTab(value: string | null): AgentDetailTab { - if (value === "configuration") return value; +function parseAgentDetailView(value: string | null): AgentDetailView { + if (value === "configure" || value === "configuration") return "configure"; if (value === "runs") return value; - if (value === "issues") return value; - if (value === "costs") return value; - if (value === "keys") return value; return "overview"; } @@ -227,7 +223,7 @@ export function AgentDetail() { const navigate = useNavigate(); const [actionError, setActionError] = useState(null); const [moreOpen, setMoreOpen] = useState(false); - const activeTab = urlRunId ? "runs" as AgentDetailTab : parseAgentDetailTab(urlTab ?? null); + const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null); const [configDirty, setConfigDirty] = useState(false); const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); @@ -339,11 +335,25 @@ export function AgentDetail() { }); useEffect(() => { - setBreadcrumbs([ + const crumbs: { label: string; href?: string }[] = [ { label: "Agents", href: "/agents" }, - { label: agent?.name ?? agentId ?? "Agent" }, - ]); - }, [setBreadcrumbs, agent, agentId]); + ]; + const agentName = agent?.name ?? agentId ?? "Agent"; + if (activeView === "overview" && !urlRunId) { + crumbs.push({ label: agentName }); + } else { + crumbs.push({ label: agentName, href: `/agents/${agentId}` }); + if (urlRunId) { + crumbs.push({ label: "Runs", href: `/agents/${agentId}/runs` }); + crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); + } else if (activeView === "configure") { + crumbs.push({ label: "Configure" }); + } else if (activeView === "runs") { + crumbs.push({ label: "Runs" }); + } + } + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, agent, agentId, activeView, urlRunId]); useEffect(() => { closePanel(); @@ -358,17 +368,11 @@ export function AgentDetail() { }, [configDirty]), ); - const setActiveTab = useCallback((nextTab: string) => { - if (configDirty && !window.confirm("You have unsaved changes. Discard them?")) return; - const next = parseAgentDetailTab(nextTab); - navigate(`/agents/${agentId}/${next}`, { replace: !!urlRunId }); - }, [agentId, navigate, configDirty, urlRunId]); - if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!agent) return null; const isPendingApproval = agent.status === "pending_approval"; - const showConfigActionBar = activeTab === "configuration" && configDirty; + const showConfigActionBar = activeView === "configure" && configDirty; return (
@@ -379,12 +383,12 @@ export function AgentDetail() { value={agent.icon} onChange={(icon) => updateIcon.mutate(icon)} > -
-

{agent.name}

+

{agent.name}

{roleLabels[agent.role] ?? agent.role} {agent.title ? ` - ${agent.title}` : ""} @@ -432,16 +436,16 @@ export function AgentDetail() { )} {mobileLiveRun && ( - + )} {/* Overflow menu */} @@ -550,165 +554,40 @@ export function AgentDetail() {

)} - - + )} - {/* OVERVIEW TAB */} - -
- {/* Summary card */} -
-

Summary

-
- - {adapterLabels[agent.adapterType] ?? agent.adapterType} - {String((agent.adapterConfig as Record)?.model ?? "") !== "" && ( - - ({String((agent.adapterConfig as Record).model)}) - - )} - - - {(agent.runtimeConfig as Record)?.heartbeat - ? (() => { - const hb = (agent.runtimeConfig as Record).heartbeat as Record; - if (!hb.enabled) return Disabled; - const sec = Number(hb.intervalSec) || 300; - const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1)); - const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`; - return ( - - Every {intervalLabel} - {maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""} - - ); - })() - : Not configured - } - - - {agent.lastHeartbeatAt - ? {relativeTime(agent.lastHeartbeatAt)} - : Never - } - -
-
+ {activeView === "configure" && ( + + )} - {/* Org card */} -
-

Organization

-
- - {reportsToAgent ? ( - - - - ) : ( - Nobody (top-level) - )} - - {directReports.length > 0 && ( -
- Direct reports -
- {directReports.map((r) => ( - - - - - {r.name} - ({roleLabels[r.role] ?? r.role}) - - ))} -
-
- )} - {agent.capabilities && ( -
- Capabilities -

{agent.capabilities}

-
- )} -
-
-
- - -
- - {/* RUNS TAB */} - - - - - {/* CONFIGURATION TAB */} - - - - - {/* ISSUES TAB */} - - {assignedIssues.length === 0 ? ( -

No assigned issues.

- ) : ( -
- {assignedIssues.map((issue) => ( - navigate(`/issues/${issue.identifier ?? issue.id}`)} - trailing={} - /> - ))} -
- )} -
- - {/* COSTS TAB */} - - - - - {/* KEYS TAB */} - - - -
+ {activeView === "runs" && ( + + )}
); } @@ -736,7 +615,6 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued"); const run = liveRun ?? sorted[0]; const isLive = run.status === "running" || run.status === "queued"; - const metrics = runMetrics(run); const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; const summary = run.resultJson @@ -758,12 +636,12 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin )}

{isLive ? "Live Run" : "Latest Run"}

- +
@@ -786,12 +664,654 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin

{summary}

)} - {(metrics.totalTokens > 0 || metrics.cost > 0) && ( -
- {metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tokens} - {metrics.cost > 0 && ${metrics.cost.toFixed(3)}} +
+ ); +} + +/* ---- Agent Overview (main single-page view) ---- */ + +function AgentOverview({ + agent, + runs, + assignedIssues, + runtimeState, + reportsToAgent, + directReports, + agentId, +}: { + agent: Agent; + runs: HeartbeatRun[]; + assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; + runtimeState?: AgentRuntimeState; + reportsToAgent: Agent | null; + directReports: Agent[]; + agentId: string; +}) { + const navigate = useNavigate(); + + return ( +
+ {/* Latest Run */} + + + {/* Charts */} +
+ + + + + + + + + + + + +
+ + {/* Recent Issues */} +
+

Recent Issues ({assignedIssues.length})

+ {assignedIssues.length === 0 ? ( +

No assigned issues.

+ ) : ( +
+ {assignedIssues.slice(0, 10).map((issue) => ( + } + /> + ))} + {assignedIssues.length > 10 && ( +
+ +{assignedIssues.length - 10} more issues +
+ )} +
+ )} +
+ + {/* Costs */} +
+

Costs

+ +
+ + {/* Configuration Summary */} + +
+ ); +} + +/* ---- Chart Components ---- */ + +function getLast14Days(): string[] { + return Array.from({ length: 14 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - (13 - i)); + return d.toISOString().slice(0, 10); + }); +} + +function formatDayLabel(dateStr: string): string { + const d = new Date(dateStr + "T12:00:00"); + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +function DateLabels({ days }: { days: string[] }) { + return ( +
+ {days.map((day, i) => ( +
+ {(i === 0 || i === 6 || i === 13) ? ( + {formatDayLabel(day)} + ) : null} +
+ ))} +
+ ); +} + +function ChartLegend({ items }: { items: { color: string; label: string }[] }) { + return ( +
+ {items.map(item => ( + + + {item.label} + + ))} +
+ ); +} + +function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle && {subtitle}} +
+ {children} +
+ ); +} + +function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) { + const days = getLast14Days(); + + const grouped = new Map(); + for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 }); + for (const run of runs) { + const day = new Date(run.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + if (run.status === "succeeded") entry.succeeded++; + else if (run.status === "failed" || run.status === "timed_out") entry.failed++; + else entry.other++; + } + + const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1); + const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0); + + if (!hasData) return

No runs yet

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = entry.succeeded + entry.failed + entry.other; + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {entry.succeeded > 0 &&
} + {entry.failed > 0 &&
} + {entry.other > 0 &&
} +
+ ) : ( +
+ )} +
+ ); + })} +
+ +
+ ); +} + +const priorityColors: Record = { + critical: "#ef4444", + high: "#f97316", + medium: "#eab308", + low: "#6b7280", +}; + +const priorityOrder = ["critical", "high", "medium", "low"] as const; + +function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) { + const days = getLast14Days(); + const grouped = new Map>(); + for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 }); + for (const issue of issues) { + const day = new Date(issue.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + if (issue.priority in entry) entry[issue.priority]++; + } + + const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1); + const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0); + + if (!hasData) return

No issues

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = Object.values(entry).reduce((a, b) => a + b, 0); + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {priorityOrder.map(p => entry[p] > 0 ? ( +
+ ) : null)} +
+ ) : ( +
+ )} +
+ ); + })} +
+ + ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} /> +
+ ); +} + +const statusColors: Record = { + todo: "#3b82f6", + in_progress: "#8b5cf6", + in_review: "#a855f7", + done: "#10b981", + blocked: "#ef4444", + cancelled: "#6b7280", + backlog: "#64748b", +}; + +const statusLabels: Record = { + todo: "To Do", + in_progress: "In Progress", + in_review: "In Review", + done: "Done", + blocked: "Blocked", + cancelled: "Cancelled", + backlog: "Backlog", +}; + +function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) { + const days = getLast14Days(); + const allStatuses = new Set(); + const grouped = new Map>(); + for (const day of days) grouped.set(day, {}); + for (const issue of issues) { + const day = new Date(issue.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + entry[issue.status] = (entry[issue.status] ?? 0) + 1; + allStatuses.add(issue.status); + } + + const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s)); + const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1); + const hasData = allStatuses.size > 0; + + if (!hasData) return

No issues

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = Object.values(entry).reduce((a, b) => a + b, 0); + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {statusOrder.map(s => (entry[s] ?? 0) > 0 ? ( +
+ ) : null)} +
+ ) : ( +
+ )} +
+ ); + })} +
+ + ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} /> +
+ ); +} + +function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) { + const days = getLast14Days(); + const grouped = new Map(); + for (const day of days) grouped.set(day, { succeeded: 0, total: 0 }); + for (const run of runs) { + const day = new Date(run.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + entry.total++; + if (run.status === "succeeded") entry.succeeded++; + } + + const hasData = Array.from(grouped.values()).some(v => v.total > 0); + if (!hasData) return

No runs yet

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const rate = entry.total > 0 ? entry.succeeded / entry.total : 0; + const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"; + return ( +
0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}> + {entry.total > 0 ? ( +
+ ) : ( +
+ )} +
+ ); + })} +
+ +
+ ); +} + +/* ---- Configuration Summary ---- */ + +function ConfigSummary({ + agent, + agentId, + reportsToAgent, + directReports, +}: { + agent: Agent; + agentId: string; + reportsToAgent: Agent | null; + directReports: Agent[]; +}) { + const navigate = useNavigate(); + const config = agent.adapterConfig as Record; + const promptText = typeof config?.promptTemplate === "string" ? config.promptTemplate : ""; + + return ( +
+
+

Configuration

+ +
+
+
+

Agent Details

+
+ + {adapterLabels[agent.adapterType] ?? agent.adapterType} + {String(config?.model ?? "") !== "" && ( + + ({String(config.model)}) + + )} + + + {(agent.runtimeConfig as Record)?.heartbeat + ? (() => { + const hb = (agent.runtimeConfig as Record).heartbeat as Record; + if (!hb.enabled) return Disabled; + const sec = Number(hb.intervalSec) || 300; + const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1)); + const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`; + return ( + + Every {intervalLabel} + {maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""} + + ); + })() + : Not configured + } + + + {agent.lastHeartbeatAt + ? {relativeTime(agent.lastHeartbeatAt)} + : Never + } + + + {reportsToAgent ? ( + + + + ) : ( + Nobody (top-level) + )} + +
+ {directReports.length > 0 && ( +
+ Direct reports +
+ {directReports.map((r) => ( + + + + + {r.name} + ({roleLabels[r.role] ?? r.role}) + + ))} +
+
+ )} + {agent.capabilities && ( +
+ Capabilities +

{agent.capabilities}

+
+ )} +
+ {promptText && ( +
+

Prompt Template

+
{promptText}
+
+ )} +
+
+ ); +} + +/* ---- Costs Section (inline) ---- */ + +function CostsSection({ + runtimeState, + runs, +}: { + runtimeState?: AgentRuntimeState; + runs: HeartbeatRun[]; +}) { + const runsWithCost = runs + .filter((r) => { + const u = r.usageJson as Record | null; + return u && (u.cost_usd || u.total_cost_usd || u.input_tokens); + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return ( +
+ {runtimeState && ( +
+
+
+ Input tokens + {formatTokens(runtimeState.totalInputTokens)} +
+
+ Output tokens + {formatTokens(runtimeState.totalOutputTokens)} +
+
+ Cached tokens + {formatTokens(runtimeState.totalCachedInputTokens)} +
+
+ Total cost + {formatCents(runtimeState.totalCostCents)} +
+
)} + {runsWithCost.length > 0 && ( +
+ + + + + + + + + + + + {runsWithCost.slice(0, 10).map((run) => { + const u = run.usageJson as Record; + return ( + + + + + + + + ); + })} + +
DateRunInputOutputCost
{formatDate(run.createdAt)}{run.id.slice(0, 8)}{formatTokens(Number(u.input_tokens ?? 0))}{formatTokens(Number(u.output_tokens ?? 0))} + {(u.cost_usd || u.total_cost_usd) + ? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}` + : "-" + } +
+
+ )} +
+ ); +} + +/* ---- Agent Configure Page ---- */ + +function AgentConfigurePage({ + agent, + agentId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, + updatePermissions, +}: { + agent: Agent; + agentId: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; + updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; +}) { + const queryClient = useQueryClient(); + const [revisionsOpen, setRevisionsOpen] = useState(false); + + const { data: configRevisions } = useQuery({ + queryKey: queryKeys.agents.configRevisions(agent.id), + queryFn: () => agentsApi.listConfigRevisions(agent.id), + }); + + const rollbackConfig = useMutation({ + mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + }, + }); + + return ( +
+ +
+

API Keys

+ +
+ + {/* Configuration Revisions โ€” collapsible at the bottom */} +
+ + {revisionsOpen && ( +
+ {(configRevisions ?? []).length === 0 ? ( +

No configuration revisions yet.

+ ) : ( +
+ {(configRevisions ?? []).slice(0, 10).map((revision) => ( +
+
+
+ {revision.id.slice(0, 8)} + ยท + {formatDate(revision.createdAt)} + ยท + {revision.source} +
+ +
+

+ Changed:{" "} + {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} +

+
+ ))} +
+ )} +
+ )} +
); } @@ -828,92 +1348,43 @@ function ConfigurationTab({ }, }); - const { data: configRevisions } = useQuery({ - queryKey: queryKeys.agents.configRevisions(agent.id), - queryFn: () => agentsApi.listConfigRevisions(agent.id), - }); - - const rollbackConfig = useMutation({ - mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); - }, - }); - useEffect(() => { onSavingChange(updateAgent.isPending); }, [onSavingChange, updateAgent.isPending]); return ( -
-
- updateAgent.mutate(patch)} - isSaving={updateAgent.isPending} - adapterModels={adapterModels} - onDirtyChange={onDirtyChange} - onSaveActionChange={onSaveActionChange} - onCancelActionChange={onCancelActionChange} - hideInlineSave - /> -
-
-

Permissions

-
- Can create new agents - -
-
-
-
-

Configuration Revisions

- {configRevisions?.length ?? 0} -
- {(configRevisions ?? []).length === 0 ? ( -

No configuration revisions yet.

- ) : ( -
- {(configRevisions ?? []).slice(0, 10).map((revision) => ( -
-
-
- {revision.id.slice(0, 8)} - ยท - {formatDate(revision.createdAt)} - ยท - {revision.source} -
- -
-

- Changed:{" "} - {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} -

-
- ))} +
+ updateAgent.mutate(patch)} + isSaving={updateAgent.isPending} + adapterModels={adapterModels} + onDirtyChange={onDirtyChange} + onSaveActionChange={onSaveActionChange} + onCancelActionChange={onCancelActionChange} + hideInlineSave + sectionLayout="cards" + /> + +
+

Permissions

+
+
+ Can create new agents +
- )} +
); @@ -1858,91 +2329,6 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin ); } -/* ---- Costs Tab ---- */ - -function CostsTab({ - runtimeState, - runs, -}: { - runtimeState?: AgentRuntimeState; - runs: HeartbeatRun[]; -}) { - const runsWithCost = runs - .filter((r) => { - const u = r.usageJson as Record | null; - return u && (u.cost_usd || u.total_cost_usd || u.input_tokens); - }) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - return ( -
- {/* Cumulative totals */} - {runtimeState && ( -
-

Cumulative Totals

-
-
- Input tokens - {formatTokens(runtimeState.totalInputTokens)} -
-
- Output tokens - {formatTokens(runtimeState.totalOutputTokens)} -
-
- Cached tokens - {formatTokens(runtimeState.totalCachedInputTokens)} -
-
- Total cost - {formatCents(runtimeState.totalCostCents)} -
-
-
- )} - - {/* Per-run cost table */} - {runsWithCost.length > 0 && ( -
-

Per-Run Costs

-
- - - - - - - - - - - - {runsWithCost.map((run) => { - const u = run.usageJson as Record; - return ( - - - - - - - - ); - })} - -
DateRunInputOutputCost
{formatDate(run.createdAt)}{run.id.slice(0, 8)}{formatTokens(Number(u.input_tokens ?? 0))}{formatTokens(Number(u.output_tokens ?? 0))} - {(u.cost_usd || u.total_cost_usd) - ? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}` - : "-" - } -
-
-
- )} -
- ); -} - /* ---- Keys Tab ---- */ function KeysTab({ agentId }: { agentId: string }) { @@ -2027,8 +2413,8 @@ function KeysTab({ agentId }: { agentId: string }) { {/* Create new key */}
-

- +

+ Create API Key

@@ -2064,10 +2450,10 @@ function KeysTab({ agentId }: { agentId: string }) { {activeKeys.length > 0 && (

-

+

Active Keys

-
+
{activeKeys.map((key: AgentKey) => (
@@ -2091,13 +2477,13 @@ function KeysTab({ agentId }: { agentId: string }) {
)} - {/* Revoked keys (collapsed) */} + {/* Revoked keys */} {revokedKeys.length > 0 && (
-

+

Revoked Keys

-
+
{revokedKeys.map((key: AgentKey) => (
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 532f2ce4..492a77c5 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -191,7 +191,7 @@ export function Agents() {
)} - @@ -223,7 +223,7 @@ export function Agents() { key={agent.id} title={agent.name} subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`} - onClick={() => navigate(`/agents/${agent.id}`)} + to={`/agents/${agent.id}`} leading={ ) : ( @@ -263,7 +262,6 @@ export function Agents() { agentId={agent.id} runId={liveRunByAgent.get(agent.id)!.runId} liveCount={liveRunByAgent.get(agent.id)!.liveCount} - navigate={navigate} /> )} @@ -294,7 +292,7 @@ export function Agents() { {effectiveView === "org" && filteredOrg.length > 0 && (
{filteredOrg.map((node) => ( - + ))}
)} @@ -317,13 +315,11 @@ export function Agents() { function OrgTreeNode({ node, depth, - navigate, agentMap, liveRunByAgent, }: { node: OrgNode; depth: number; - navigate: (path: string) => void; agentMap: Map; liveRunByAgent: Map; }) { @@ -344,9 +340,9 @@ function OrgTreeNode({ return (
-
- + {node.reports && node.reports.length > 0 && (
{node.reports.map((child) => ( - + ))}
)} @@ -411,20 +405,16 @@ function LiveRunIndicator({ agentId, runId, liveCount, - navigate, }: { agentId: string; runId: string; liveCount: number; - navigate: (path: string) => void; }) { return ( - + ); } diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx index b9304add..2b8e8ac1 100644 --- a/ui/src/pages/ApprovalDetail.tsx +++ b/ui/src/pages/ApprovalDetail.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react"; import type { ApprovalComment } from "@paperclip/shared"; +import { MarkdownBody } from "../components/MarkdownBody"; export function ApprovalDetail() { const { approvalId } = useParams<{ approvalId: string }>(); @@ -329,7 +330,7 @@ export function ApprovalDetail() { {new Date(comment.createdAt).toLocaleString()}
-

{comment.body}

+ {comment.body}
))}
diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx new file mode 100644 index 00000000..1ec01843 --- /dev/null +++ b/ui/src/pages/Auth.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authApi } from "../api/auth"; +import { queryKeys } from "../lib/queryKeys"; +import { Button } from "@/components/ui/button"; + +type AuthMode = "sign_in" | "sign_up"; + +export function AuthPage() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [mode, setMode] = useState("sign_in"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams]); + const { data: session, isLoading: isSessionLoading } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + + useEffect(() => { + if (session) { + navigate(nextPath, { replace: true }); + } + }, [session, navigate, nextPath]); + + const mutation = useMutation({ + mutationFn: async () => { + if (mode === "sign_in") { + await authApi.signInEmail({ email: email.trim(), password }); + return; + } + await authApi.signUpEmail({ + name: name.trim(), + email: email.trim(), + password, + }); + }, + onSuccess: async () => { + setError(null); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + navigate(nextPath, { replace: true }); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : "Authentication failed"); + }, + }); + + const canSubmit = + email.trim().length > 0 && + password.trim().length >= 8 && + (mode === "sign_in" || name.trim().length > 0); + + if (isSessionLoading) { + return
Loading...
; + } + + return ( +
+
+

+ {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"} +

+

+ {mode === "sign_in" + ? "Use your email and password to access this instance." + : "Create an account for this instance. Email confirmation is not required in v1."} +

+ +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + {mode === "sign_up" && ( + + )} + + + {error &&

{error}

} + +
+ +
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "} + +
+
+
+ ); +} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index ee4df951..99ed2d90 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,8 +1,9 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { companiesApi } from "../api/companies"; +import { accessApi } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Settings } from "lucide-react"; @@ -11,6 +12,10 @@ export function CompanySettings() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const [joinType, setJoinType] = useState<"human" | "agent" | "both">("both"); + const [expiresInHours, setExpiresInHours] = useState(72); + const [inviteLink, setInviteLink] = useState(null); + const [inviteError, setInviteError] = useState(null); const settingsMutation = useMutation({ mutationFn: (requireApproval: boolean) => @@ -22,6 +27,31 @@ export function CompanySettings() { }, }); + const inviteMutation = useMutation({ + mutationFn: () => + accessApi.createCompanyInvite(selectedCompanyId!, { + allowedJoinTypes: joinType, + expiresInHours, + }), + onSuccess: (invite) => { + setInviteError(null); + const base = window.location.origin.replace(/\/+$/, ""); + const absoluteUrl = invite.inviteUrl.startsWith("http") + ? invite.inviteUrl + : `${base}${invite.inviteUrl}`; + setInviteLink(absoluteUrl); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); + }, + onError: (err) => { + setInviteError(err instanceof Error ? err.message : "Failed to create invite"); + }, + }); + + const inviteExpiryHint = useMemo(() => { + const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000); + return expiresAt.toLocaleString(); + }, [expiresInHours]); + useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, @@ -75,6 +105,63 @@ export function CompanySettings() {
+ +
+
+ Invites +
+
+
+ + +
+

Invite will expire around {inviteExpiryHint}.

+
+ + {inviteLink && ( + + )} +
+ {inviteError &&

{inviteError}

} + {inviteLink && ( +
+
Share link
+
{inviteLink}
+
+ )} +
+
); } diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 42c09d59..e89185c7 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; @@ -31,7 +31,6 @@ export function Dashboard() { const { selectedCompanyId, selectedCompany, companies } = useCompany(); const { openOnboarding } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); const [animatedActivityIds, setAnimatedActivityIds] = useState>(new Set()); const seenActivityIdsRef = useRef>(new Set()); const hydratedActivityRef = useRef(false); @@ -180,14 +179,12 @@ export function Dashboard() { icon={Bot} value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error} label="Agents Enabled" - onClick={() => navigate("/agents")} + to="/agents" description={ - navigate("/agents")}>{data.agents.running} running - {", "} - navigate("/agents")}>{data.agents.paused} paused - {", "} - navigate("/agents")}>{data.agents.error} errors + {data.agents.running} running{", "} + {data.agents.paused} paused{", "} + {data.agents.error} errors } /> @@ -195,12 +192,11 @@ export function Dashboard() { icon={CircleDot} value={data.tasks.inProgress} label="Tasks In Progress" - onClick={() => navigate("/issues")} + to="/issues" description={ - navigate("/issues")}>{data.tasks.open} open - {", "} - navigate("/issues")}>{data.tasks.blocked} blocked + {data.tasks.open} open{", "} + {data.tasks.blocked} blocked } /> @@ -208,9 +204,9 @@ export function Dashboard() { icon={DollarSign} value={formatCents(data.costs.monthSpendCents)} label="Month Spend" - onClick={() => navigate("/costs")} + to="/costs" description={ - navigate("/costs")}> + {data.costs.monthBudgetCents > 0 ? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget` : "Unlimited budget"} @@ -221,9 +217,9 @@ export function Dashboard() { icon={ShieldCheck} value={data.pendingApprovals} label="Pending Approvals" - onClick={() => navigate("/approvals")} + to="/approvals" description={ - navigate("/issues")}> + {data.staleTasks} stale tasks } @@ -263,10 +259,10 @@ export function Dashboard() { ) : (
{recentIssues.slice(0, 10).map((issue) => ( -
navigate(`/issues/${issue.identifier ?? issue.id}`)} + to={`/issues/${issue.identifier ?? issue.id}`} + className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block" >
@@ -288,7 +284,7 @@ export function Dashboard() { {timeAgo(issue.updatedAt)}
-
+ ))}
)} diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx index 6d36aa58..a3132be3 100644 --- a/ui/src/pages/GoalDetail.tsx +++ b/ui/src/pages/GoalDetail.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; import { projectsApi } from "../api/projects"; @@ -25,42 +25,54 @@ export function GoalDetail() { const { openNewGoal } = useDialog(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: goal, isLoading, error } = useQuery({ + const { + data: goal, + isLoading, + error + } = useQuery({ queryKey: queryKeys.goals.detail(goalId!), queryFn: () => goalsApi.get(goalId!), - enabled: !!goalId, + enabled: !!goalId }); const { data: allGoals } = useQuery({ queryKey: queryKeys.goals.list(selectedCompanyId!), queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId }); const { data: allProjects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId }); const updateGoal = useMutation({ - mutationFn: (data: Record) => goalsApi.update(goalId!, data), + mutationFn: (data: Record) => + goalsApi.update(goalId!, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(goalId!) }); + queryClient.invalidateQueries({ + queryKey: queryKeys.goals.detail(goalId!) + }); if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(selectedCompanyId) }); + queryClient.invalidateQueries({ + queryKey: queryKeys.goals.list(selectedCompanyId) + }); } - }, + } }); const uploadImage = useMutation({ mutationFn: async (file: File) => { if (!selectedCompanyId) throw new Error("No company selected"); - return assetsApi.uploadImage(selectedCompanyId, file, `goals/${goalId ?? "draft"}`); - }, + return assetsApi.uploadImage( + selectedCompanyId, + file, + `goals/${goalId ?? "draft"}` + ); + } }); const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId); @@ -74,20 +86,24 @@ export function GoalDetail() { useEffect(() => { setBreadcrumbs([ { label: "Goals", href: "/goals" }, - { label: goal?.title ?? goalId ?? "Goal" }, + { label: goal?.title ?? goalId ?? "Goal" } ]); }, [setBreadcrumbs, goal, goalId]); useEffect(() => { if (goal) { openPanel( - updateGoal.mutate(data)} /> + updateGoal.mutate(data)} + /> ); } return () => closePanel(); }, [goal]); // eslint-disable-line react-hooks/exhaustive-deps - if (isLoading) return

Loading...

; + if (isLoading) + return

Loading...

; if (error) return

{error.message}

; if (!goal) return null; @@ -95,7 +111,9 @@ export function GoalDetail() {
- {goal.level} + + {goal.level} +
@@ -122,13 +140,21 @@ export function GoalDetail() { - Sub-Goals ({childGoals.length}) - Projects ({linkedProjects.length}) + + Sub-Goals ({childGoals.length}) + + + Projects ({linkedProjects.length}) + -
- @@ -136,10 +162,7 @@ export function GoalDetail() { {childGoals.length === 0 ? (

No sub-goals.

) : ( - navigate(`/goals/${g.id}`)} - /> + `/goals/${g.id}`} /> )} @@ -153,7 +176,7 @@ export function GoalDetail() { key={project.id} title={project.name} subtitle={project.description ?? undefined} - onClick={() => navigate(`/projects/${project.id}`)} + to={`/projects/${project.id}`} trailing={} /> ))} diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx index ad7998f6..51514b2b 100644 --- a/ui/src/pages/Goals.tsx +++ b/ui/src/pages/Goals.tsx @@ -1,5 +1,4 @@ import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; import { useCompany } from "../context/CompanyContext"; @@ -15,7 +14,6 @@ export function Goals() { const { selectedCompanyId } = useCompany(); const { openNewGoal } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); useEffect(() => { setBreadcrumbs([{ label: "Goals" }]); @@ -47,13 +45,13 @@ export function Goals() { {goals && goals.length > 0 && ( <> -
+
- navigate(`/goals/${goal.id}`)} /> + `/goals/${goal.id}`} /> )}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index c4a5a333..37fed0c1 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; +import { accessApi } from "../api/access"; +import { ApiError } from "../api/client"; import { dashboardApi } from "../api/dashboard"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -17,19 +19,39 @@ import { StatusBadge } from "../components/StatusBadge"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Tabs } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Inbox as InboxIcon, AlertTriangle, Clock, - ExternalLink, ArrowUpRight, XCircle, } from "lucide-react"; import { Identity } from "../components/Identity"; -import type { HeartbeatRun, Issue } from "@paperclip/shared"; +import { PageTabBar } from "../components/PageTabBar"; +import type { HeartbeatRun, Issue, JoinRequest } from "@paperclip/shared"; const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); +const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); + +type InboxTab = "new" | "all"; +type InboxCategoryFilter = + | "everything" + | "join_requests" + | "approvals" + | "failed_runs" + | "alerts" + | "stale_work"; +type InboxApprovalFilter = "all" | "actionable" | "resolved"; +type SectionKey = "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work"; const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", @@ -44,12 +66,9 @@ function getStaleIssues(issues: Issue[]): Issue[] { .filter( (i) => ["in_progress", "todo"].includes(i.status) && - now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS + now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS, ) - .sort( - (a, b) => - new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - ); + .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); } function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { @@ -64,9 +83,7 @@ function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { } } - return Array.from(latestByAgent.values()).filter((run) => - FAILED_RUN_STATUSES.has(run.status), - ); + return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status)); } function firstNonEmptyLine(value: string | null | undefined): string | null { @@ -76,11 +93,7 @@ function firstNonEmptyLine(value: string | null | undefined): string | null { } function runFailureMessage(run: HeartbeatRun): string { - return ( - firstNonEmptyLine(run.error) ?? - firstNonEmptyLine(run.stderrExcerpt) ?? - "Run exited with an error." - ); + return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error."; } function readIssueIdFromRun(run: HeartbeatRun): string | null { @@ -100,8 +113,14 @@ export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); + const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); + const [allApprovalFilter, setAllApprovalFilter] = useState("all"); + + const pathSegment = location.pathname.split("/").pop() ?? "new"; + const tab: InboxTab = pathSegment === "all" ? "all" : "new"; const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -113,25 +132,48 @@ export function Inbox() { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); - const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({ + const { + data: approvals, + isLoading: isApprovalsLoading, + error: approvalsError, + } = useQuery({ queryKey: queryKeys.approvals.list(selectedCompanyId!), queryFn: () => approvalsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); - const { data: dashboard } = useQuery({ + const { + data: joinRequests = [], + isLoading: isJoinRequestsLoading, + } = useQuery({ + queryKey: queryKeys.access.joinRequests(selectedCompanyId!), + queryFn: async () => { + try { + return await accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"); + } catch (err) { + if (err instanceof ApiError && (err.status === 403 || err.status === 401)) { + return []; + } + throw err; + } + }, + enabled: !!selectedCompanyId, + retry: false, + }); + + const { data: dashboard, isLoading: isDashboardLoading } = useQuery({ queryKey: queryKeys.dashboard(selectedCompanyId!), queryFn: () => dashboardApi.summary(selectedCompanyId!), enabled: !!selectedCompanyId, }); - const { data: issues } = useQuery({ + const { data: issues, isLoading: isIssuesLoading } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); - const { data: heartbeatRuns } = useQuery({ + const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!), queryFn: () => heartbeatsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, @@ -156,6 +198,28 @@ export function Inbox() { [heartbeatRuns], ); + const allApprovals = useMemo( + () => + [...(approvals ?? [])].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ), + [approvals], + ); + + const actionableApprovals = useMemo( + () => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)), + [allApprovals], + ); + + const filteredAllApprovals = useMemo(() => { + if (allApprovalFilter === "all") return allApprovals; + + return allApprovals.filter((approval) => { + const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); + return allApprovalFilter === "actionable" ? isActionable : !isActionable; + }); + }, [allApprovals, allApprovalFilter]); + const agentName = (id: string | null) => { if (!id) return null; return agentById.get(id) ?? null; @@ -164,6 +228,7 @@ export function Inbox() { const approveMutation = useMutation({ mutationFn: (id: string) => approvalsApi.approve(id), onSuccess: (_approval, id) => { + setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); navigate(`/approvals/${id}?resolved=approved`); }, @@ -175,6 +240,7 @@ export function Inbox() { const rejectMutation = useMutation({ mutationFn: (id: string) => approvalsApi.reject(id), onSuccess: () => { + setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); }, onError: (err) => { @@ -182,67 +248,251 @@ export function Inbox() { }, }); + const approveJoinMutation = useMutation({ + mutationFn: (joinRequest: JoinRequest) => + accessApi.approveJoinRequest(selectedCompanyId!, joinRequest.id), + onSuccess: () => { + setActionError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Failed to approve join request"); + }, + }); + + const rejectJoinMutation = useMutation({ + mutationFn: (joinRequest: JoinRequest) => + accessApi.rejectJoinRequest(selectedCompanyId!, joinRequest.id), + onSuccess: () => { + setActionError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Failed to reject join request"); + }, + }); + if (!selectedCompanyId) { return ; } - const actionableApprovals = (approvals ?? []).filter( - (approval) => approval.status === "pending" || approval.status === "revision_requested", - ); - const hasActionableApprovals = actionableApprovals.length > 0; const hasRunFailures = failedRuns.length > 0; - const showAggregateAgentError = - !!dashboard && dashboard.agents.error > 0 && !hasRunFailures; - const hasAlerts = + const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures; + const showBudgetAlert = !!dashboard && - (showAggregateAgentError || (dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthUtilizationPercent >= 80)); + dashboard.costs.monthBudgetCents > 0 && + dashboard.costs.monthUtilizationPercent >= 80; + const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasStale = staleIssues.length > 0; - const hasContent = hasActionableApprovals || hasRunFailures || hasAlerts || hasStale; + const hasJoinRequests = joinRequests.length > 0; + + const newItemCount = + joinRequests.length + + actionableApprovals.length + + failedRuns.length + + staleIssues.length + + (showAggregateAgentError ? 1 : 0) + + (showBudgetAlert ? 1 : 0); + + const showJoinRequestsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; + const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals"; + const showFailedRunsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; + const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; + const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work"; + + const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; + const showJoinRequestsSection = + tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests; + const showApprovalsSection = + tab === "new" + ? actionableApprovals.length > 0 + : showApprovalsCategory && filteredAllApprovals.length > 0; + const showFailedRunsSection = + tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; + const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; + const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale; + + const visibleSections = [ + showApprovalsSection ? "approvals" : null, + showJoinRequestsSection ? "join_requests" : null, + showFailedRunsSection ? "failed_runs" : null, + showAlertsSection ? "alerts" : null, + showStaleSection ? "stale_work" : null, + ].filter((key): key is SectionKey => key !== null); + + const isLoading = + isJoinRequestsLoading || + isApprovalsLoading || + isDashboardLoading || + isIssuesLoading || + isRunsLoading; + + const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; return (
- {isApprovalsLoading &&

Loading...

} - {error &&

{error.message}

} +
+ navigate(`/inbox/${value === "all" ? "all" : "new"}`)}> + + New + {newItemCount > 0 && ( + + {newItemCount} + + )} + + ), + }, + { value: "all", label: "All" }, + ]} + /> + + + {tab === "all" && ( +
+ + + {showApprovalsCategory && ( + + )} +
+ )} +
+ + {isLoading &&

Loading...

} + {approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} - {!isApprovalsLoading && !hasContent && ( - + {!isLoading && visibleSections.length === 0 && ( + )} - {/* Pending Approvals */} - {hasActionableApprovals && ( -
-
-

- Approvals -

- -
-
- {actionableApprovals.map((approval) => ( - a.id === approval.requestedByAgentId) ?? null : null} - onApprove={() => approveMutation.mutate(approval.id)} - onReject={() => rejectMutation.mutate(approval.id)} - onOpen={() => navigate(`/approvals/${approval.id}`)} - isPending={approveMutation.isPending || rejectMutation.isPending} - /> - ))} -
-
- )} - - {/* Failed Runs */} - {hasRunFailures && ( + {showApprovalsSection && ( <> - {hasActionableApprovals && } + {showSeparatorBefore("approvals") && } +
+

+ {tab === "new" ? "Approvals Needing Action" : "Approvals"} +

+
+ {approvalsToRender.map((approval) => ( + a.id === approval.requestedByAgentId) ?? null + : null + } + onApprove={() => approveMutation.mutate(approval.id)} + onReject={() => rejectMutation.mutate(approval.id)} + detailLink={`/approvals/${approval.id}`} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> + ))} +
+
+ + )} + + {showJoinRequestsSection && ( + <> + {showSeparatorBefore("join_requests") && } +
+

+ Join Requests +

+
+ {joinRequests.map((joinRequest) => ( +
+
+
+

+ {joinRequest.requestType === "human" + ? "Human join request" + : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`} +

+

+ requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp} +

+ {joinRequest.requestEmailSnapshot && ( +

+ email: {joinRequest.requestEmailSnapshot} +

+ )} + {joinRequest.adapterType && ( +

adapter: {joinRequest.adapterType}

+ )} +
+
+ + +
+
+
+ ))} +
+
+ + )} + + {showFailedRunsSection && ( + <> + {showSeparatorBefore("failed_runs") && }

Failed Runs @@ -268,9 +518,11 @@ export function Inbox() { - {linkedAgentName - ? - : Agent {run.agentId.slice(0, 8)}} + {linkedAgentName ? ( + + ) : ( + Agent {run.agentId.slice(0, 8)} + )}

@@ -282,10 +534,12 @@ export function Inbox() { variant="outline" size="sm" className="h-8 px-2.5" - onClick={() => navigate(`/agents/${run.agentId}/runs/${run.id}`)} + asChild > - Open run - + + Open run + +

@@ -296,13 +550,12 @@ export function Inbox() {
run {run.id.slice(0, 8)} {issue ? ( - + ) : ( {run.errorCode ? `code: ${run.errorCode}` : "No linked issue"} @@ -318,61 +571,57 @@ export function Inbox() { )} - {/* Alerts */} - {hasAlerts && ( + {showAlertsSection && ( <> - {(hasActionableApprovals || hasRunFailures) && } + {showSeparatorBefore("alerts") && }

Alerts

{showAggregateAgentError && ( -
navigate("/agents")} + {dashboard!.agents.error}{" "} {dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors -
+ )} - {dashboard!.costs.monthBudgetCents > 0 && dashboard!.costs.monthUtilizationPercent >= 80 && ( -
navigate("/costs")} + {showBudgetAlert && ( + Budget at{" "} - - {dashboard!.costs.monthUtilizationPercent}% - {" "} + {dashboard!.costs.monthUtilizationPercent}%{" "} utilization this month -
+ )}
)} - {/* Stale Work */} - {hasStale && ( + {showStaleSection && ( <> - {(hasActionableApprovals || hasRunFailures || hasAlerts) && } + {showSeparatorBefore("stale_work") && }

Stale Work

{staleIssues.map((issue) => ( -
navigate(`/issues/${issue.identifier ?? issue.id}`)} + to={`/issues/${issue.identifier ?? issue.id}`} + className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit" > @@ -381,16 +630,21 @@ export function Inbox() { {issue.identifier ?? issue.id.slice(0, 8)} {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : {issue.assigneeAgentId.slice(0, 8)}; - })()} + {issue.assigneeAgentId && + (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : ( + + {issue.assigneeAgentId.slice(0, 8)} + + ); + })()} updated {timeAgo(issue.updatedAt)} -
+ ))}
diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx new file mode 100644 index 00000000..d8a8a540 --- /dev/null +++ b/ui/src/pages/InviteLanding.tsx @@ -0,0 +1,236 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { accessApi } from "../api/access"; +import { authApi } from "../api/auth"; +import { healthApi } from "../api/health"; +import { queryKeys } from "../lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import type { JoinRequest } from "@paperclip/shared"; + +type JoinType = "human" | "agent"; + +function dateTime(value: string) { + return new Date(value).toLocaleString(); +} + +export function InviteLandingPage() { + const queryClient = useQueryClient(); + const params = useParams(); + const token = (params.token ?? "").trim(); + const [joinType, setJoinType] = useState("human"); + const [agentName, setAgentName] = useState(""); + const [adapterType, setAdapterType] = useState(""); + const [capabilities, setCapabilities] = useState(""); + const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null); + const [error, setError] = useState(null); + + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + }); + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const inviteQuery = useQuery({ + queryKey: queryKeys.access.invite(token), + queryFn: () => accessApi.getInvite(token), + enabled: token.length > 0, + retry: false, + }); + + const invite = inviteQuery.data; + const allowedJoinTypes = invite?.allowedJoinTypes ?? "both"; + const availableJoinTypes = useMemo(() => { + if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[]; + if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[]; + return [allowedJoinTypes] as JoinType[]; + }, [invite?.inviteType, allowedJoinTypes]); + + useEffect(() => { + if (!availableJoinTypes.includes(joinType)) { + setJoinType(availableJoinTypes[0] ?? "human"); + } + }, [availableJoinTypes, joinType]); + + const requiresAuthForHuman = + joinType === "human" && + healthQuery.data?.deploymentMode === "authenticated" && + !sessionQuery.data; + + const acceptMutation = useMutation({ + mutationFn: async () => { + if (!invite) throw new Error("Invite not found"); + if (invite.inviteType === "bootstrap_ceo") { + return accessApi.acceptInvite(token, { requestType: "human" }); + } + if (joinType === "human") { + return accessApi.acceptInvite(token, { requestType: "human" }); + } + return accessApi.acceptInvite(token, { + requestType: "agent", + agentName: agentName.trim(), + adapterType: adapterType.trim() || undefined, + capabilities: capabilities.trim() || null, + }); + }, + onSuccess: async (payload) => { + setError(null); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + const asBootstrap = + payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record); + setResult({ kind: asBootstrap ? "bootstrap" : "join", payload }); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : "Failed to accept invite"); + }, + }); + + if (!token) { + return
Invalid invite token.
; + } + + if (inviteQuery.isLoading || healthQuery.isLoading || sessionQuery.isLoading) { + return
Loading invite...
; + } + + if (inviteQuery.error || !invite) { + return ( +
+
+

Invite not available

+

+ This invite may be expired, revoked, or already used. +

+
+
+ ); + } + + if (result?.kind === "bootstrap") { + return ( +
+
+

Bootstrap complete

+

+ The first instance admin is now configured. You can continue to the board. +

+ +
+
+ ); + } + + if (result?.kind === "join") { + const payload = result.payload as JoinRequest; + return ( +
+
+

Join request submitted

+

+ Your request is pending admin approval. You will not have access until approved. +

+
+ Request ID: {payload.id} +
+
+
+ ); + } + + return ( +
+
+

+ {invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"} +

+

Invite expires {dateTime(invite.expiresAt)}.

+ + {invite.inviteType !== "bootstrap_ceo" && ( +
+ {availableJoinTypes.map((type) => ( + + ))} +
+ )} + + {joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && ( +
+ + +