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() {
-
+
{/* 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 && (
- navigate(`/agents/${agent.id}/runs/${mobileLiveRun.id}`)}
+
Live
-
+
)}
{/* 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"}
-
navigate(`/agents/${agentId}/runs/${run.id}`)}
+
View details →
-
+
@@ -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
+ navigate(`/agents/${agentId}/configure`)}
+ >
+
+ Manage →
+
+
+
+
+
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 && (
+
+
+
+
+ | Date |
+ Run |
+ Input |
+ Output |
+ Cost |
+
+
+
+ {runsWithCost.slice(0, 10).map((run) => {
+ const u = run.usageJson as Record;
+ return (
+
+ | {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 */}
+
+
setRevisionsOpen((v) => !v)}
+ >
+ {revisionsOpen
+ ?
+ :
+ }
+ Configuration Revisions
+ {configRevisions?.length ?? 0}
+
+ {revisionsOpen && (
+
+ {(configRevisions ?? []).length === 0 ? (
+
No configuration revisions yet.
+ ) : (
+
+ {(configRevisions ?? []).slice(0, 10).map((revision) => (
+
+
+
+ {revision.id.slice(0, 8)}
+ ยท
+ {formatDate(revision.createdAt)}
+ ยท
+ {revision.source}
+
+
rollbackConfig.mutate(revision.id)}
+ disabled={rollbackConfig.isPending}
+ >
+ Restore
+
+
+
+ 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
-
- updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
- }
- disabled={updatePermissions.isPending}
- >
- {agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
-
-
-
-
-
-
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}
-
-
rollbackConfig.mutate(revision.id)}
- disabled={rollbackConfig.isPending}
- >
- Restore
-
-
-
- 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
+
+ updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
+ }
+ disabled={updatePermissions.isPending}
+ >
+ {agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
+
- )}
+
);
@@ -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
-
-
-
-
- | Date |
- Run |
- Input |
- Output |
- Cost |
-
-
-
- {runsWithCost.map((run) => {
- const u = run.usageJson as Record;
- return (
-
- | {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() {
)}
-
+
New Agent
@@ -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 (
- navigate(`/agents/${node.id}`)}
+
@@ -365,7 +361,6 @@ function OrgTreeNode({
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
- navigate={navigate}
/>
) : (
@@ -377,7 +372,6 @@ function OrgTreeNode({
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
- navigate={navigate}
/>
)}
{agent && (
@@ -395,11 +389,11 @@ function OrgTreeNode({
-
+
{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 (
-
{
- e.stopPropagation();
- navigate(`/agents/${agentId}/runs/${runId}`);
- }}
+ e.stopPropagation()}
>
@@ -433,6 +423,6 @@ function LiveRunIndicator({
Live{liveCount > 1 ? ` (${liveCount})` : ""}
-
+
);
}
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."}
+
+
+
+
+
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
+ {
+ setError(null);
+ setMode(mode === "sign_in" ? "sign_up" : "sign_in");
+ }}
+ >
+ {mode === "sign_in" ? "Create one" : "Sign in"}
+
+
+
+
+ );
+}
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}.
+
+ inviteMutation.mutate()} disabled={inviteMutation.isPending}>
+ {inviteMutation.isPending ? "Creating..." : "Create invite link"}
+
+ {inviteLink && (
+ {
+ await navigator.clipboard.writeText(inviteLink);
+ }}
+ >
+ Copy link
+
+ )}
+
+ {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})
+
-
-
openNewGoal({ parentId: goalId })}>
+
+
openNewGoal({ parentId: goalId })}
+ >
Sub Goal
@@ -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
-
- navigate("/approvals")}
- >
- See all 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}
+ )}
+
+
+ rejectJoinMutation.mutate(joinRequest)}
+ >
+ Reject
+
+ approveJoinMutation.mutate(joinRequest)}
+ >
+ Approve
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ {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 ? (
-
navigate(`/issues/${issue.identifier ?? issue.id}`)}
+
{issue.identifier ?? issue.id.slice(0, 8)} ยท {issue.title}
-
+
) : (
{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.
+
+
+ Open 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) => (
+ setJoinType(type)}
+ className={`rounded-md border px-3 py-1.5 text-sm ${
+ joinType === type
+ ? "border-foreground bg-foreground text-background"
+ : "border-border bg-background text-foreground"
+ }`}
+ >
+ Join as {type}
+
+ ))}
+
+ )}
+
+ {joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
+
+
+
+
+
+ )}
+
+ {requiresAuthForHuman && (
+
+ Sign in or create an account before submitting a human join request.
+
+
+ Sign in / Create account
+
+
+
+ )}
+
+ {error &&
{error}
}
+
+
acceptMutation.mutate()}
+ >
+ {acceptMutation.isPending
+ ? "Submitting..."
+ : invite.inviteType === "bootstrap_ceo"
+ ? "Accept bootstrap invite"
+ : "Submit join request"}
+
+
+
+ );
+}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 2fac5e89..e88f3557 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -122,8 +122,6 @@ export function IssueDetail() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [moreOpen, setMoreOpen] = useState(false);
- const [projectOpen, setProjectOpen] = useState(false);
- const [projectSearch, setProjectSearch] = useState("");
const [attachmentError, setAttachmentError] = useState(null);
const fileInputRef = useRef(null);
@@ -412,54 +410,20 @@ export function IssueDetail() {
/>
{issue.identifier ?? issue.id.slice(0, 8)}
- { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
-
-
-
- {issue.projectId
- ? ((projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8))
- : No project
- }
-
-
-
- setProjectSearch(e.target.value)}
- autoFocus
- />
- { updateIssue.mutate({ projectId: null }); setProjectOpen(false); }}
- >
- No project
-
- {(projects ?? [])
- .filter((p) => {
- if (!projectSearch.trim()) return true;
- return p.name.toLowerCase().includes(projectSearch.toLowerCase());
- })
- .map((p) => (
- { updateIssue.mutate({ projectId: p.id }); setProjectOpen(false); }}
- >
-
- {p.name}
-
- ))
- }
-
-
+ {issue.projectId ? (
+
+
+ {(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}
+
+ ) : (
+
+
+ No project
+
+ )}
@@ -591,6 +555,10 @@ export function IssueDetail() {
onAdd={async (body, reopen) => {
await addComment.mutateAsync({ body, reopen });
}}
+ imageUploadHandler={async (file) => {
+ const attachment = await uploadAttachment.mutateAsync(file);
+ return attachment.contentPath;
+ }}
/>
{childIssues.length > 0 && (
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 195718f2..aaf5c287 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -1,69 +1,19 @@
import { useEffect, useMemo } from "react";
-import { useNavigate, useLocation } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
-import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
-import { groupBy } from "../lib/groupBy";
-import { StatusIcon } from "../components/StatusIcon";
-import { PriorityIcon } from "../components/PriorityIcon";
-import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
-import { PageTabBar } from "../components/PageTabBar";
-import { Button } from "@/components/ui/button";
-import { Tabs } from "@/components/ui/tabs";
-import { CircleDot, Plus } from "lucide-react";
-import { formatDate } from "../lib/utils";
-import { Identity } from "../components/Identity";
-import type { Issue } from "@paperclip/shared";
-
-const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
-
-function statusLabel(status: string): string {
- return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
-}
-
-type TabFilter = "all" | "active" | "backlog" | "done" | "recent";
-
-const issueTabItems = [
- { value: "all", label: "All Issues" },
- { value: "active", label: "Active" },
- { value: "backlog", label: "Backlog" },
- { value: "done", label: "Done" },
- { value: "recent", label: "Recent" },
-] as const;
-
-function parseIssueTab(value: string | null): TabFilter {
- if (value === "all" || value === "active" || value === "backlog" || value === "done" || value === "recent") return value;
- return "active";
-}
-
-function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
- switch (tab) {
- case "active":
- return issues.filter((i) => ["todo", "in_progress", "in_review", "blocked"].includes(i.status));
- case "backlog":
- return issues.filter((i) => i.status === "backlog");
- case "done":
- return issues.filter((i) => ["done", "cancelled"].includes(i.status));
- default:
- return issues;
- }
-}
+import { IssuesList } from "../components/IssuesList";
+import { CircleDot } from "lucide-react";
export function Issues() {
const { selectedCompanyId } = useCompany();
- const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
- const navigate = useNavigate();
const queryClient = useQueryClient();
- const location = useLocation();
- const pathSegment = location.pathname.split("/").pop() ?? "active";
- const tab = parseIssueTab(pathSegment);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -104,164 +54,19 @@ export function Issues() {
},
});
- const agentName = (id: string | null) => {
- if (!id || !agents) return null;
- return agents.find((a) => a.id === id)?.name ?? null;
- };
-
if (!selectedCompanyId) {
return ;
}
- const filtered = filterIssues(issues ?? [], tab);
- const recentSorted = tab === "recent"
- ? [...filtered].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
- : null;
- const grouped = groupBy(filtered, (i) => i.status);
- const orderedGroups = statusOrder
- .filter((s) => grouped[s]?.length)
- .map((s) => ({ status: s, items: grouped[s]! }));
-
- const setTab = (nextTab: TabFilter) => {
- navigate(`/issues/${nextTab}`);
- };
-
return (
-
-
-
setTab(v as TabFilter)}>
- setTab(v as TabFilter)} />
-
-
openNewIssue()}>
-
- New Issue
-
-
-
- {isLoading &&
Loading...
}
- {error &&
{error.message}
}
-
- {issues && filtered.length === 0 && (
-
openNewIssue()}
- />
- )}
-
- {recentSorted ? (
-
- {recentSorted.map((issue) => (
-
navigate(`/issues/${issue.identifier ?? issue.id}`)}
- leading={
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
- e.stopPropagation()}>
-
updateIssue.mutate({ id: issue.id, data: { priority: p } })}
- />
- updateIssue.mutate({ id: issue.id, data: { status: s } })}
- />
-
- }
- trailing={
-
- {liveIssueIds.has(issue.id) && (
-
-
-
-
-
- Live
-
- )}
- {issue.assigneeAgentId && (() => {
- const name = agentName(issue.assigneeAgentId);
- return name
- ?
- : {issue.assigneeAgentId.slice(0, 8)};
- })()}
-
- {formatDate(issue.updatedAt)}
-
-
- }
- />
- ))}
-
- ) : (
- orderedGroups.map(({ status, items }) => (
-
-
-
-
- {statusLabel(status)}
-
-
{items.length}
-
openNewIssue({ status })}
- >
-
-
-
-
- {items.map((issue) => (
-
navigate(`/issues/${issue.identifier ?? issue.id}`)}
- leading={
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
- e.stopPropagation()}>
-
updateIssue.mutate({ id: issue.id, data: { priority: p } })}
- />
- updateIssue.mutate({ id: issue.id, data: { status: s } })}
- />
-
- }
- trailing={
-
- {liveIssueIds.has(issue.id) && (
-
-
-
-
-
- Live
-
- )}
- {issue.assigneeAgentId && (() => {
- const name = agentName(issue.assigneeAgentId);
- return name
- ?
- : {issue.assigneeAgentId.slice(0, 8)};
- })()}
-
- {formatDate(issue.createdAt)}
-
-
- }
- />
- ))}
-
-
- ))
- )}
-
+ updateIssue.mutate({ id, data })}
+ />
);
}
diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx
index 0d69c410..c65e761a 100644
--- a/ui/src/pages/MyIssues.tsx
+++ b/ui/src/pages/MyIssues.tsx
@@ -1,5 +1,4 @@
import { useEffect } from "react";
-import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
@@ -15,7 +14,6 @@ import { ListTodo } from "lucide-react";
export function MyIssues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
- const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "My Issues" }]);
@@ -52,7 +50,7 @@ export function MyIssues() {
key={issue.id}
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
- onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
+ to={`/issues/${issue.identifier ?? issue.id}`}}
leading={
<>
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index 07345ac9..1d775bc2 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -100,7 +100,7 @@ function ColorPicker({
aria-label="Change project color"
/>
{open && (
-
+
{PROJECT_COLORS.map((color) => (
-
-
updateProject.mutate({ color })}
- />
+
+
+ updateProject.mutate({ color })}
+ />
+
updateProject.mutate({ name })}
diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx
index 53b7f60f..c4af6867 100644
--- a/ui/src/pages/Projects.tsx
+++ b/ui/src/pages/Projects.tsx
@@ -1,5 +1,4 @@
import { useEffect } from "react";
-import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -17,7 +16,6 @@ export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
- const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
@@ -36,7 +34,7 @@ export function Projects() {
return (
-
+
Add Project
@@ -61,7 +59,7 @@ export function Projects() {
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
- onClick={() => navigate(`/projects/${project.id}`)}
+ to={`/projects/${project.id}`}
trailing={
{project.targetDate && (