feat(ui): add auth pages, company rail, inbox redesign, and page improvements

Add Auth sign-in/sign-up page and InviteLanding page for invite acceptance.
Add CloudAccessGate that checks deployment mode and redirects to /auth when
session is required. Add CompanyRail with drag-and-drop company switching.
Add MarkdownBody prose renderer. Redesign Inbox with category filters and
inline join-request approval. Refactor AgentDetail to overview/configure/runs
views with claude-login support. Replace navigate() anti-patterns with <Link>
components in Dashboard and MetricCard. Add live-run indicators in sidebar
agents. Fix LiveUpdatesProvider cache key resolution for issue identifiers.
Add auth, health, and access API clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 14:41:21 -06:00
parent 5b983ca4d3
commit 2ec45c49af
48 changed files with 2794 additions and 1067 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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() {
</button>
</div>
)}
<Button size="sm" onClick={openNewAgent}>
<Button size="sm" variant="outline" onClick={openNewAgent}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Agent
</Button>
@@ -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={
<span className="relative flex h-2.5 w-2.5">
<span
@@ -251,7 +251,6 @@ export function Agents() {
agentId={agent.id}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
navigate={navigate}
/>
) : (
<StatusBadge status={agent.status} />
@@ -263,7 +262,6 @@ export function Agents() {
agentId={agent.id}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
navigate={navigate}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
@@ -294,7 +292,7 @@ export function Agents() {
{effectiveView === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1">
{filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
<OrgTreeNode key={node.id} node={node} depth={0} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
@@ -317,13 +315,11 @@ export function Agents() {
function OrgTreeNode({
node,
depth,
navigate,
agentMap,
liveRunByAgent,
}: {
node: OrgNode;
depth: number;
navigate: (path: string) => void;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
}) {
@@ -344,9 +340,9 @@ function OrgTreeNode({
return (
<div style={{ paddingLeft: depth * 24 }}>
<button
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left"
onClick={() => navigate(`/agents/${node.id}`)}
<Link
to={`/agents/${node.id}`}
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit"
>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
@@ -365,7 +361,6 @@ function OrgTreeNode({
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
navigate={navigate}
/>
) : (
<StatusBadge status={node.status} />
@@ -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({
</span>
</div>
</div>
</button>
</Link>
{node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4">
{node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
<OrgTreeNode key={child.id} node={child} depth={depth + 1} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
@@ -411,20 +405,16 @@ function LiveRunIndicator({
agentId,
runId,
liveCount,
navigate,
}: {
agentId: string;
runId: string;
liveCount: number;
navigate: (path: string) => void;
}) {
return (
<button
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${agentId}/runs/${runId}`);
}}
<Link
to={`/agents/${agentId}/runs/${runId}`}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
onClick={(e) => e.stopPropagation()}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
@@ -433,6 +423,6 @@ function LiveRunIndicator({
<span className="text-[11px] font-medium text-blue-400">
Live{liveCount > 1 ? ` (${liveCount})` : ""}
</span>
</button>
</Link>
);
}

View File

@@ -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()}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
</div>
))}
</div>

141
ui/src/pages/Auth.tsx Normal file
View File

@@ -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<AuthMode>("sign_in");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(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 <div className="mx-auto max-w-md py-16 text-sm text-muted-foreground">Loading...</div>;
}
return (
<div className="mx-auto max-w-md py-10">
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
{mode === "sign_up" && (
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Name</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
/>
</label>
)}
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Email</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Password</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={!canSubmit || mutation.isPending} className="w-full">
{mutation.isPending
? "Working..."
: mode === "sign_in"
? "Sign In"
: "Create Account"}
</Button>
</form>
<div className="mt-4 text-sm text-muted-foreground">
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
<button
type="button"
className="font-medium text-foreground underline underline-offset-2"
onClick={() => {
setError(null);
setMode(mode === "sign_in" ? "sign_up" : "sign_in");
}}
>
{mode === "sign_in" ? "Create one" : "Sign in"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(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() {
</Button>
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Invites
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block text-muted-foreground">Allowed join type</span>
<select
className="w-full rounded-md border border-border bg-background px-2 py-2 text-sm"
value={joinType}
onChange={(event) => setJoinType(event.target.value as "human" | "agent" | "both")}
>
<option value="both">Human or agent</option>
<option value="human">Human only</option>
<option value="agent">Agent only</option>
</select>
</label>
<label className="text-sm">
<span className="mb-1 block text-muted-foreground">Expires in hours</span>
<input
className="w-full rounded-md border border-border bg-background px-2 py-2 text-sm"
type="number"
min={1}
max={720}
value={expiresInHours}
onChange={(event) => setExpiresInHours(Math.max(1, Math.min(720, Number(event.target.value) || 72)))}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">Invite will expire around {inviteExpiryHint}.</p>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}>
{inviteMutation.isPending ? "Creating..." : "Create invite link"}
</Button>
{inviteLink && (
<Button
size="sm"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(inviteLink);
}}
>
Copy link
</Button>
)}
</div>
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
{inviteLink && (
<div className="rounded-md border border-border bg-muted/30 p-2">
<div className="text-xs text-muted-foreground">Share link</div>
<div className="mt-1 break-all font-mono text-xs">{inviteLink}</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<Set<string>>(new Set());
const seenActivityIdsRef = useRef<Set<string>>(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={
<span>
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.running} running</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
{data.agents.running} running{", "}
{data.agents.paused} paused{", "}
{data.agents.error} errors
</span>
}
/>
@@ -195,12 +192,11 @@ export function Dashboard() {
icon={CircleDot}
value={data.tasks.inProgress}
label="Tasks In Progress"
onClick={() => navigate("/issues")}
to="/issues"
description={
<span>
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.open} open</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.blocked} blocked</span>
{data.tasks.open} open{", "}
{data.tasks.blocked} blocked
</span>
}
/>
@@ -208,9 +204,9 @@ export function Dashboard() {
icon={DollarSign}
value={formatCents(data.costs.monthSpendCents)}
label="Month Spend"
onClick={() => navigate("/costs")}
to="/costs"
description={
<span className="cursor-pointer" onClick={() => navigate("/costs")}>
<span>
{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={
<span className="cursor-pointer" onClick={() => navigate("/issues")}>
<span>
{data.staleTasks} stale tasks
</span>
}
@@ -263,10 +259,10 @@ export function Dashboard() {
) : (
<div className="border border-border divide-y divide-border">
{recentIssues.slice(0, 10).map((issue) => (
<div
<Link
key={issue.id}
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => 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"
>
<div className="flex gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
@@ -288,7 +284,7 @@ export function Dashboard() {
{timeAgo(issue.updatedAt)}
</span>
</div>
</div>
</Link>
))}
</div>
)}

View File

@@ -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<string, unknown>) => goalsApi.update(goalId!, data),
mutationFn: (data: Record<string, unknown>) =>
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(
<GoalProperties goal={goal} onUpdate={(data) => updateGoal.mutate(data)} />
<GoalProperties
goal={goal}
onUpdate={(data) => updateGoal.mutate(data)}
/>
);
}
return () => closePanel();
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (isLoading)
return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!goal) return null;
@@ -95,7 +111,9 @@ export function GoalDetail() {
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs uppercase text-muted-foreground">{goal.level}</span>
<span className="text-xs uppercase text-muted-foreground">
{goal.level}
</span>
<StatusBadge status={goal.status} />
</div>
@@ -122,13 +140,21 @@ export function GoalDetail() {
<Tabs defaultValue="children">
<TabsList>
<TabsTrigger value="children">Sub-Goals ({childGoals.length})</TabsTrigger>
<TabsTrigger value="projects">Projects ({linkedProjects.length})</TabsTrigger>
<TabsTrigger value="children">
Sub-Goals ({childGoals.length})
</TabsTrigger>
<TabsTrigger value="projects">
Projects ({linkedProjects.length})
</TabsTrigger>
</TabsList>
<TabsContent value="children" className="mt-4 space-y-3">
<div className="flex items-center justify-end">
<Button size="sm" variant="outline" onClick={() => openNewGoal({ parentId: goalId })}>
<div className="flex items-center justify-start">
<Button
size="sm"
variant="outline"
onClick={() => openNewGoal({ parentId: goalId })}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Sub Goal
</Button>
@@ -136,10 +162,7 @@ export function GoalDetail() {
{childGoals.length === 0 ? (
<p className="text-sm text-muted-foreground">No sub-goals.</p>
) : (
<GoalTree
goals={childGoals}
onSelect={(g) => navigate(`/goals/${g.id}`)}
/>
<GoalTree goals={childGoals} goalLink={(g) => `/goals/${g.id}`} />
)}
</TabsContent>
@@ -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={<StatusBadge status={project.status} />}
/>
))}

View File

@@ -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 && (
<>
<div className="flex items-center justify-end">
<div className="flex items-center justify-start">
<Button size="sm" variant="outline" onClick={() => openNewGoal()}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Goal
</Button>
</div>
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
<GoalTree goals={goals} goalLink={(goal) => `/goals/${goal.id}`} />
</>
)}
</div>

View File

@@ -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<string, string> = {
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<string | null>(null);
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("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 <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
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 (
<div className="space-y-6">
{isApprovalsLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
<PageTabBar
items={[
{
value: "new",
label: (
<>
New
{newItemCount > 0 && (
<span className="ml-1.5 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-500">
{newItemCount}
</span>
)}
</>
),
},
{ value: "all", label: "All" },
]}
/>
</Tabs>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
<SelectItem value="stale_work">Stale work</SelectItem>
</SelectContent>
</Select>
{showApprovalsCategory && (
<Select
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!isApprovalsLoading && !hasContent && (
<EmptyState icon={InboxIcon} message="You're all caught up!" />
{!isLoading && visibleSections.length === 0 && (
<EmptyState
icon={InboxIcon}
message={tab === "new" ? "You're all caught up!" : "No inbox items match these filters."}
/>
)}
{/* Pending Approvals */}
{hasActionableApprovals && (
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Approvals
</h3>
<button
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate("/approvals")}
>
See all approvals <ExternalLink className="ml-0.5 inline h-3 w-3" />
</button>
</div>
<div className="grid gap-3">
{actionableApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => 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}
/>
))}
</div>
</div>
)}
{/* Failed Runs */}
{hasRunFailures && (
{showApprovalsSection && (
<>
{hasActionableApprovals && <Separator />}
{showSeparatorBefore("approvals") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
</h3>
<div className="grid gap-3">
{approvalsToRender.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={
approval.requestedByAgentId
? (agents ?? []).find((a) => 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}
/>
))}
</div>
</div>
</>
)}
{showJoinRequestsSection && (
<>
{showSeparatorBefore("join_requests") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Join Requests
</h3>
<div className="grid gap-3">
{joinRequests.map((joinRequest) => (
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
</p>
<p className="text-xs text-muted-foreground">
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
</p>
{joinRequest.requestEmailSnapshot && (
<p className="text-xs text-muted-foreground">
email: {joinRequest.requestEmailSnapshot}
</p>
)}
{joinRequest.adapterType && (
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => rejectJoinMutation.mutate(joinRequest)}
>
Reject
</Button>
<Button
size="sm"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => approveJoinMutation.mutate(joinRequest)}
>
Approve
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
{showFailedRunsSection && (
<>
{showSeparatorBefore("failed_runs") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Failed Runs
@@ -268,9 +518,11 @@ export function Inbox() {
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-400" />
</span>
{linkedAgentName
? <Identity name={linkedAgentName} size="sm" />
: <span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>}
{linkedAgentName ? (
<Identity name={linkedAgentName} size="sm" />
) : (
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
)}
<StatusBadge status={run.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
@@ -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
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
Open run
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</div>
@@ -296,13 +550,12 @@ export function Inbox() {
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
{issue ? (
<button
type="button"
className="truncate text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="truncate text-muted-foreground transition-colors hover:text-foreground no-underline"
>
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
</button>
</Link>
) : (
<span className="text-muted-foreground">
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
@@ -318,61 +571,57 @@ export function Inbox() {
</>
)}
{/* Alerts */}
{hasAlerts && (
{showAlertsSection && (
<>
{(hasActionableApprovals || hasRunFailures) && <Separator />}
{showSeparatorBefore("alerts") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Alerts
</h3>
<div className="divide-y divide-border border border-border">
{showAggregateAgentError && (
<div
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/agents")}
<Link
to="/agents"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
<span className="text-sm">
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
</span>
</div>
</Link>
)}
{dashboard!.costs.monthBudgetCents > 0 && dashboard!.costs.monthUtilizationPercent >= 80 && (
<div
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/costs")}
{showBudgetAlert && (
<Link
to="/costs"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
<span className="text-sm">
Budget at{" "}
<span className="font-medium">
{dashboard!.costs.monthUtilizationPercent}%
</span>{" "}
<span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "}
utilization this month
</span>
</div>
</Link>
)}
</div>
</div>
</>
)}
{/* Stale Work */}
{hasStale && (
{showStaleSection && (
<>
{(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
{showSeparatorBefore("stale_work") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Stale Work
</h3>
<div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => (
<div
<Link
key={issue.id}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => 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"
>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<PriorityIcon priority={issue.priority} />
@@ -381,16 +630,21 @@ export function Inbox() {
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="font-mono text-xs text-muted-foreground">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="sm" />
) : (
<span className="font-mono text-xs text-muted-foreground">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
})()}
<span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)}
</span>
</div>
</Link>
))}
</div>
</div>

View File

@@ -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<JoinType>("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<string | null>(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<string, unknown>);
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to accept invite");
},
});
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
}
if (inviteQuery.isLoading || healthQuery.isLoading || sessionQuery.isLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading invite...</div>;
}
if (inviteQuery.error || !invite) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Invite not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
This invite may be expired, revoked, or already used.
</p>
</div>
</div>
);
}
if (result?.kind === "bootstrap") {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
<p className="mt-2 text-sm text-muted-foreground">
The first instance admin is now configured. You can continue to the board.
</p>
<Button asChild className="mt-4">
<Link to="/">Open board</Link>
</Button>
</div>
</div>
);
}
if (result?.kind === "join") {
const payload = result.payload as JoinRequest;
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Join request submitted</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your request is pending admin approval. You will not have access until approved.
</p>
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
Request ID: <span className="font-mono">{payload.id}</span>
</div>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
{invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-5 flex gap-2">
{availableJoinTypes.map((type) => (
<button
key={type}
type="button"
onClick={() => 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}
</button>
))}
</div>
)}
{joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-4 space-y-3">
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Agent name</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Adapter type (optional)</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={adapterType}
onChange={(event) => setAdapterType(event.target.value)}
placeholder="process"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Capabilities (optional)</span>
<textarea
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
</div>
)}
{requiresAuthForHuman && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
Sign in or create an account before submitting a human join request.
<div className="mt-2">
<Button asChild size="sm" variant="outline">
<Link to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}>Sign in / Create account</Link>
</Button>
</div>
</div>
)}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<Button
className="mt-5"
disabled={
acceptMutation.isPending ||
(joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && agentName.trim().length === 0) ||
requiresAuthForHuman
}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending
? "Submitting..."
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Submit join request"}
</Button>
</div>
</div>
);
}

View File

@@ -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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -412,54 +410,20 @@ export function IssueDetail() {
/>
<span className="text-xs font-mono text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<Popover open={projectOpen} onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5">
<Hexagon className="h-3 w-3 shrink-0" />
{issue.projectId
? ((projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8))
: <span className="opacity-50">No project</span>
}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.projectId && "bg-accent"
)}
onClick={() => { updateIssue.mutate({ projectId: null }); setProjectOpen(false); }}
>
No project
</button>
{(projects ?? [])
.filter((p) => {
if (!projectSearch.trim()) return true;
return p.name.toLowerCase().includes(projectSearch.toLowerCase());
})
.map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { updateIssue.mutate({ projectId: p.id }); setProjectOpen(false); }}
>
<Hexagon className="h-3 w-3 text-muted-foreground shrink-0" />
{p.name}
</button>
))
}
</PopoverContent>
</Popover>
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5"
>
<Hexagon className="h-3 w-3 shrink-0" />
{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
<Hexagon className="h-3 w-3 shrink-0" />
No project
</span>
)}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
@@ -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 && (

View File

@@ -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 <EmptyState icon={CircleDot} message="Select a company to view issues." />;
}
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 (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
<PageTabBar items={[...issueTabItems]} value={tab} onValueChange={(v) => setTab(v as TabFilter)} />
</Tabs>
<Button size="sm" onClick={() => openNewIssue()}>
<Plus className="h-4 w-4 mr-1" />
New Issue
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{issues && filtered.length === 0 && (
<EmptyState
icon={CircleDot}
message="No issues found."
action="Create Issue"
onAction={() => openNewIssue()}
/>
)}
{recentSorted ? (
<div className="border border-border">
{recentSorted.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
leading={
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<PriorityIcon
priority={issue.priority}
onChange={(p) => updateIssue.mutate({ id: issue.id, data: { priority: p } })}
/>
<StatusIcon
status={issue.status}
onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })}
/>
</div>
}
trailing={
<div className="flex items-center gap-3">
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
</span>
)}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground">
{formatDate(issue.updatedAt)}
</span>
</div>
}
/>
))}
</div>
) : (
orderedGroups.map(({ status, items }) => (
<div key={status}>
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50">
<StatusIcon status={status} />
<span className="text-xs font-semibold uppercase tracking-wide">
{statusLabel(status)}
</span>
<span className="text-xs text-muted-foreground">{items.length}</span>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue({ status })}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="border border-border">
{items.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
leading={
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<PriorityIcon
priority={issue.priority}
onChange={(p) => updateIssue.mutate({ id: issue.id, data: { priority: p } })}
/>
<StatusIcon
status={issue.status}
onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })}
/>
</div>
}
trailing={
<div className="flex items-center gap-3">
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
</span>
)}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</div>
}
/>
))}
</div>
</div>
))
)}
</div>
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
error={error as Error | null}
agents={agents}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:issues-view"
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
}

View File

@@ -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={
<>
<PriorityIcon priority={issue.priority} />

View File

@@ -100,7 +100,7 @@ function ColorPicker({
aria-label="Change project color"
/>
{open && (
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50">
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
<div className="grid grid-cols-5 gap-1.5">
{PROJECT_COLORS.map((color) => (
<button
@@ -252,11 +252,13 @@ export function ProjectDetail() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ColorPicker
currentColor={project.color ?? "#6366f1"}
onSelect={(color) => updateProject.mutate({ color })}
/>
<div className="flex items-start gap-3">
<div className="h-7 flex items-center">
<ColorPicker
currentColor={project.color ?? "#6366f1"}
onSelect={(color) => updateProject.mutate({ color })}
/>
</div>
<InlineEditor
value={project.name}
onSave={(name) => updateProject.mutate({ name })}

View File

@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Button size="sm" onClick={openNewProject}>
<Button size="sm" variant="outline" onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
</Button>
@@ -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={
<div className="flex items-center gap-3">
{project.targetDate && (