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:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
141
ui/src/pages/Auth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
236
ui/src/pages/InviteLanding.tsx
Normal file
236
ui/src/pages/InviteLanding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user