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

View File

@@ -1,5 +1,8 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Layout } from "./components/Layout";
import { authApi } from "./api/auth";
import { healthApi } from "./api/health";
import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
@@ -17,43 +20,113 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { AuthPage } from "./pages/Auth";
import { InviteLandingPage } from "./pages/InviteLanding";
import { queryKeys } from "./lib/queryKeys";
function BootstrapPendingPage() {
return (
<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">Instance setup required</h1>
<p className="mt-2 text-sm text-muted-foreground">
No instance admin exists yet. Run this command in your Paperclip environment to generate
the first admin invite URL:
</p>
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{`pnpm paperclip auth bootstrap-ceo`}
</pre>
</div>
</div>
);
}
function CloudAccessGate() {
const location = useLocation();
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
});
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: isAuthenticatedMode,
retry: false,
});
if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
if (healthQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
</div>
);
}
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
return <BootstrapPendingPage />;
}
if (isAuthenticatedMode && !sessionQuery.data) {
const next = encodeURIComponent(`${location.pathname}${location.search}`);
return <Navigate to={`/auth?next=${next}`} replace />;
}
return <Outlet />;
}
export function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="org" element={<Navigate to="/agents/all" replace />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
<Route path="agents/active" element={<Agents />} />
<Route path="agents/paused" element={<Agents />} />
<Route path="agents/error" element={<Agents />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="issues" element={<Navigate to="/issues/active" replace />} />
<Route path="issues/all" element={<Issues />} />
<Route path="issues/active" element={<Issues />} />
<Route path="issues/backlog" element={<Issues />} />
<Route path="issues/done" element={<Issues />} />
<Route path="issues/recent" element={<Issues />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Inbox />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="auth" element={<AuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}>
<Route element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="org" element={<Navigate to="/agents/all" replace />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
<Route path="agents/active" element={<Agents />} />
<Route path="agents/paused" element={<Agents />} />
<Route path="agents/error" element={<Agents />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="design-guide" element={<DesignGuide />} />
</Route>
</Route>
</Routes>
);

52
ui/src/api/access.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { JoinRequest } from "@paperclip/shared";
import { api } from "./client";
type InviteSummary = {
id: string;
companyId: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
expiresAt: string;
};
type AcceptInviteInput =
| { requestType: "human" }
| {
requestType: "agent";
agentName: string;
adapterType?: string;
capabilities?: string | null;
agentDefaultsPayload?: Record<string, unknown> | null;
};
export const accessApi = {
createCompanyInvite: (
companyId: string,
input: {
allowedJoinTypes?: "human" | "agent" | "both";
expiresInHours?: number;
defaultsPayload?: Record<string, unknown> | null;
} = {},
) =>
api.post<{
id: string;
token: string;
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
}>(`/companies/${companyId}/invites`, input),
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
acceptInvite: (token: string, input: AcceptInviteInput) =>
api.post<JoinRequest | { bootstrapAccepted: true; userId: string }>(`/invites/${token}/accept`, input),
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
approveJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
rejectJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/reject`, {}),
};

View File

@@ -22,6 +22,15 @@ export interface AdapterModel {
label: string;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
loginUrl: string | null;
stdout: string;
stderr: string;
}
export interface OrgNode {
id: string;
name: string;
@@ -87,4 +96,5 @@ export const agentsApi = {
idempotencyKey?: string | null;
},
) => api.post<HeartbeatRun | { status: "skipped" }>(`/agents/${id}/wakeup`, data),
loginWithClaude: (id: string) => api.post<ClaudeLoginResult>(`/agents/${id}/claude-login`, {}),
};

74
ui/src/api/auth.ts Normal file
View File

@@ -0,0 +1,74 @@
export type AuthSession = {
session: { id: string; userId: string };
user: { id: string; email: string | null; name: string | null };
};
function toSession(value: unknown): AuthSession | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const sessionValue = record.session;
const userValue = record.user;
if (!sessionValue || typeof sessionValue !== "object") return null;
if (!userValue || typeof userValue !== "object") return null;
const session = sessionValue as Record<string, unknown>;
const user = userValue as Record<string, unknown>;
if (typeof session.id !== "string" || typeof session.userId !== "string") return null;
if (typeof user.id !== "string") return null;
return {
session: { id: session.id, userId: session.userId },
user: {
id: user.id,
email: typeof user.email === "string" ? user.email : null,
name: typeof user.name === "string" ? user.name : null,
},
};
}
async function authPost(path: string, body: Record<string, unknown>) {
const res = await fetch(`/api/auth${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const message =
(payload as { error?: { message?: string } | string } | null)?.error &&
typeof (payload as { error?: { message?: string } | string }).error === "object"
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
throw new Error(message);
}
return payload;
}
export const authApi = {
getSession: async (): Promise<AuthSession | null> => {
const res = await fetch("/api/auth/get-session", {
credentials: "include",
headers: { Accept: "application/json" },
});
if (res.status === 401) return null;
const payload = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(`Failed to load session (${res.status})`);
}
const direct = toSession(payload);
if (direct) return direct;
const nested = payload && typeof payload === "object" ? toSession((payload as Record<string, unknown>).data) : null;
return nested;
},
signInEmail: async (input: { email: string; password: string }) => {
await authPost("/sign-in/email", input);
},
signUpEmail: async (input: { name: string; email: string; password: string }) => {
await authPost("/sign-up/email", input);
},
signOut: async () => {
await authPost("/sign-out", {});
},
};

View File

@@ -1,5 +1,17 @@
const BASE = "/api";
export class ApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? undefined);
const body = init?.body;
@@ -9,11 +21,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers,
credentials: "include",
...init,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `Request failed: ${res.status}`);
const errorBody = await res.json().catch(() => null);
throw new ApiError(
(errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`,
res.status,
errorBody,
);
}
return res.json();
}

20
ui/src/api/health.ts Normal file
View File

@@ -0,0 +1,20 @@
export type HealthStatus = {
status: "ok";
deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public";
authReady?: boolean;
bootstrapStatus?: "ready" | "bootstrap_pending";
};
export const healthApi = {
get: async (): Promise<HealthStatus> => {
const res = await fetch("/api/health", {
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`Failed to load health (${res.status})`);
}
return res.json();
},
};

View File

@@ -1,4 +1,7 @@
export { api } from "./client";
export { authApi } from "./auth";
export { healthApi } from "./health";
export { accessApi } from "./access";
export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";

View File

@@ -2,7 +2,12 @@ import type { Approval, Issue, IssueAttachment, IssueComment } from "@paperclip/
import { api } from "./client";
export const issuesApi = {
list: (companyId: string) => api.get<Issue[]>(`/companies/${companyId}/issues`),
list: (companyId: string, filters?: { projectId?: string }) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<Issue>(`/issues/${id}`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),

View File

@@ -1,4 +1,4 @@
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { Identity } from "./Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils";
@@ -85,8 +85,6 @@ interface ActivityRowProps {
}
export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) {
const navigate = useNavigate();
const verb = formatVerb(event.action, event.details);
const isHeartbeatEvent = event.entityType === "heartbeat_run";
@@ -104,27 +102,38 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
const inner = (
<div className="flex gap-3">
<p className="flex-1 min-w-0">
<Identity
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
size="xs"
className="align-baseline"
/>
<span className="text-muted-foreground ml-1">{verb} </span>
{name && <span className="font-medium">{name}</span>}
</p>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
</div>
);
const classes = cn(
"px-4 py-2 text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
className,
);
if (link) {
return (
<Link to={link} className={cn(classes, "no-underline text-inherit block")}>
{inner}
</Link>
);
}
return (
<div
className={cn(
"px-4 py-2 text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
className,
)}
onClick={link ? () => navigate(link) : undefined}
>
<div className="flex gap-3">
<p className="flex-1 min-w-0">
<Identity
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
size="xs"
className="align-baseline"
/>
<span className="text-muted-foreground ml-1">{verb} </span>
{name && <span className="font-medium">{name}</span>}
</p>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
</div>
<div className={classes}>
{inner}
</div>
);
}

View File

@@ -50,6 +50,8 @@ type AgentConfigFormProps = {
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
hideInlineSave?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
} & (
| {
mode: "create";
@@ -138,6 +140,7 @@ function extractPickedDirectoryPath(handle: unknown): string | null {
export function AgentConfigForm(props: AgentConfigFormProps) {
const { mode, adapterModels: externalModels } = props;
const isCreate = mode === "create";
const cards = props.sectionLayout === "cards";
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
@@ -324,7 +327,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: false;
return (
<div className="relative">
<div className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
@@ -343,9 +346,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* ---- Identity (edit only) ---- */}
{!isCreate && (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
<div className="px-4 pb-3 space-y-3">
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Identity</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Name" hint={help.name}>
<DraftInput
value={eff("identity", "name", props.agent.name)}
@@ -403,11 +409,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Adapter ---- */}
<div className={cn(isCreate ? "border-t border-border" : "border-b border-border")}>
<div className="px-4 py-2 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Adapter
</span>
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
{cards
? <h3 className="text-sm font-medium">Adapter</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
<Button
type="button"
variant="outline"
@@ -419,7 +426,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{testEnvironment.isPending ? "Testing..." : "Test environment"}
</Button>
</div>
<div className="px-4 pb-3 space-y-3">
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
@@ -531,11 +538,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* ---- Permissions & Configuration ---- */}
{isLocal && (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
Permissions & Configuration
</div>
<div className="px-4 pb-3 space-y-3">
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Permissions &amp; Configuration</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions &amp; Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
@@ -689,12 +697,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* ---- Run Policy ---- */}
{isCreate ? (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
<Heart className="h-3 w-3" />
Run Policy
</div>
<div className="px-4 pb-3 space-y-3">
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
@@ -710,30 +718,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
</div>
) : (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
<Heart className="h-3 w-3" />
Run Policy
</div>
<div className="px-4 pb-3 space-y-3">
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
/>
</div>
<CollapsibleSection
title="Advanced Run Policy"
open={runPolicyAdvancedOpen}
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
>
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
/>
</div>
<CollapsibleSection
title="Advanced Run Policy"
bordered={cards}
open={runPolicyAdvancedOpen}
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
>
<div className="space-y-3">
<ToggleField
label="Wake on demand"
@@ -771,6 +781,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
</div>
</CollapsibleSection>
</div>
</div>
)}

View File

@@ -1,4 +1,5 @@
import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Identity } from "./Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
@@ -19,13 +20,15 @@ export function ApprovalCard({
onApprove,
onReject,
onOpen,
detailLink,
isPending,
}: {
approval: Approval;
requesterAgent: Agent | null;
onApprove: () => void;
onReject: () => void;
onOpen: () => void;
onOpen?: () => void;
detailLink?: string;
isPending: boolean;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
@@ -85,9 +88,15 @@ export function ApprovalCard({
</div>
)}
<div className="mt-3">
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
View details
</Button>
{detailLink ? (
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
<Link to={detailLink}>View details</Link>
</Button>
) : (
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
View details
</Button>
)}
</div>
</div>
);

View File

@@ -33,7 +33,7 @@ export function BreadcrumbBar() {
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
return (
<div className="border-b border-border px-4 md:px-6 py-4 flex items-center">
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<h1 className="text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].label}
@@ -44,7 +44,7 @@ export function BreadcrumbBar() {
// Multiple breadcrumbs = breadcrumb trail
return (
<div className="border-b border-border px-4 md:px-6 py-3 flex items-center">
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<Breadcrumb>
<BreadcrumbList>

View File

@@ -1,9 +1,9 @@
import { useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import Markdown from "react-markdown";
import type { IssueComment, Agent } from "@paperclip/shared";
import { Button } from "@/components/ui/button";
import { Identity } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { formatDateTime } from "../lib/utils";
@@ -17,11 +17,12 @@ interface CommentThreadProps {
onAdd: (body: string, reopen?: boolean) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>;
}
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
export function CommentThread({ comments, onAdd, issueStatus, agentMap }: CommentThreadProps) {
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler }: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -35,13 +36,15 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
[comments],
);
// Build mention options from agent map
// Build mention options from agent map (exclude terminated agents)
const mentions = useMemo<MentionOption[]>(() => {
if (!agentMap) return [];
return Array.from(agentMap.values()).map((a) => ({
id: a.id,
name: a.name,
}));
return Array.from(agentMap.values())
.filter((a) => a.status !== "terminated")
.map((a) => ({
id: a.id,
name: a.name,
}));
}, [agentMap]);
async function handleSubmit() {
@@ -84,9 +87,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
{formatDateTime(comment.createdAt)}
</span>
</div>
<div className="text-sm prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
<Markdown>{comment.body}</Markdown>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{comment.runId && comment.runAgentId && (
<div className="mt-2 pt-2 border-t border-border/60">
<Link
@@ -109,6 +110,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">

View File

@@ -0,0 +1,267 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Paperclip, Plus } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { cn } from "../lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { Company } from "@paperclip/shared";
const COMPANY_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f43f5e", // rose
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#06b6d4", // cyan
"#3b82f6", // blue
];
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
function companyColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length]!;
}
function getStoredOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return [];
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
}
/** Sort companies by stored order, appending any new ones at the end. */
function sortByStoredOrder(companies: Company[]): Company[] {
const order = getStoredOrder();
if (order.length === 0) return companies;
const byId = new Map(companies.map((c) => [c.id, c]));
const sorted: Company[] = [];
for (const id of order) {
const c = byId.get(id);
if (c) {
sorted.push(c);
byId.delete(id);
}
}
// Append any companies not in stored order
for (const c of byId.values()) {
sorted.push(c);
}
return sorted;
}
function SortableCompanyItem({
company,
isSelected,
onSelect,
}: {
company: Company;
isSelected: boolean;
onSelect: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: company.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1,
};
const color = companyColor(company.name);
const initial = company.name.charAt(0).toUpperCase();
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
onClick={onSelect}
className="relative flex items-center justify-center group"
>
{/* Selection indicator pill */}
<div
className={cn(
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-all duration-200",
isSelected
? "h-5"
: "h-0 group-hover:h-2"
)}
/>
<div
className={cn(
"flex items-center justify-center w-11 h-11 text-base font-semibold text-white transition-all duration-200",
isSelected
? "rounded-xl"
: "rounded-[22px] group-hover:rounded-xl",
isDragging && "shadow-lg scale-105"
)}
style={{ backgroundColor: color }}
>
{initial}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{company.name}</p>
</TooltipContent>
</Tooltip>
</div>
);
}
export function CompanyRail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialog();
// Maintain sorted order in local state, synced from companies + localStorage
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
sortByStoredOrder(companies).map((c) => c.id)
);
// Sync order across tabs via the native storage event
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== ORDER_STORAGE_KEY) return;
try {
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
setOrderedIds(ids);
} catch { /* ignore malformed data */ }
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
// Re-derive when companies change (new company added/removed)
const orderedCompanies = useMemo(() => {
const byId = new Map(companies.map((c) => [c.id, c]));
const result: Company[] = [];
for (const id of orderedIds) {
const c = byId.get(id);
if (c) {
result.push(c);
byId.delete(id);
}
}
// Append any new companies not yet in our order
for (const c of byId.values()) {
result.push(c);
}
return result;
}, [companies, orderedIds]);
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = orderedCompanies.map((c) => c.id);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newIds = arrayMove(ids, oldIndex, newIndex);
setOrderedIds(newIds);
saveOrder(newIds);
},
[orderedCompanies]
);
return (
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
<div className="flex items-center justify-center h-12 w-full shrink-0">
<Paperclip className="h-5 w-5 text-foreground" />
</div>
{/* Company list */}
<div className="flex-1 flex flex-col items-center gap-2 py-2 overflow-y-auto scrollbar-none">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isSelected={company.id === selectedCompanyId}
onSelect={() => setSelectedCompanyId(company.id)}
/>
))}
</SortableContext>
</DndContext>
</div>
{/* Separator before add button */}
<div className="w-8 h-px bg-border mx-auto shrink-0" />
{/* Add company button */}
<div className="flex items-center justify-center py-2 shrink-0">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
onClick={() => openOnboarding()}
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-all duration-200"
>
<Plus className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>Add company</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { type ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "../lib/utils";
interface EntityRowProps {
@@ -8,6 +9,7 @@ interface EntityRowProps {
subtitle?: string;
trailing?: ReactNode;
selected?: boolean;
to?: string;
onClick?: () => void;
className?: string;
}
@@ -19,19 +21,20 @@ export function EntityRow({
subtitle,
trailing,
selected,
to,
onClick,
className,
}: EntityRowProps) {
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
onClick && "cursor-pointer hover:bg-accent/50",
selected && "bg-accent/30",
className
)}
onClick={onClick}
>
const isClickable = !!(to || onClick);
const classes = cn(
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
isClickable && "cursor-pointer hover:bg-accent/50",
selected && "bg-accent/30",
className
);
const content = (
<>
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -47,6 +50,20 @@ export function EntityRow({
)}
</div>
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
</>
);
if (to) {
return (
<Link to={to} className={cn(classes, "no-underline text-inherit")} onClick={onClick}>
{content}
</Link>
);
}
return (
<div className={classes} onClick={onClick}>
{content}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { Goal } from "@paperclip/shared";
import { Link } from "react-router-dom";
import { StatusBadge } from "./StatusBadge";
import { ChevronRight } from "lucide-react";
import { cn } from "../lib/utils";
@@ -6,6 +7,7 @@ import { useState } from "react";
interface GoalTreeProps {
goals: Goal[];
goalLink?: (goal: Goal) => string;
onSelect?: (goal: Goal) => void;
}
@@ -14,41 +16,62 @@ interface GoalNodeProps {
children: Goal[];
allGoals: Goal[];
depth: number;
goalLink?: (goal: Goal) => string;
onSelect?: (goal: Goal) => void;
}
function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) {
function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalNodeProps) {
const [expanded, setExpanded] = useState(true);
const hasChildren = children.length > 0;
const link = goalLink?.(goal);
const inner = (
<>
{hasChildren ? (
<button
className="p-0.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!expanded);
}}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
) : (
<span className="w-4" />
)}
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
<span className="flex-1 truncate">{goal.title}</span>
<StatusBadge status={goal.status} />
</>
);
const classes = cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
);
return (
<div>
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
)}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect?.(goal)}
>
{hasChildren ? (
<button
className="p-0.5"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
) : (
<span className="w-4" />
)}
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
<span className="flex-1 truncate">{goal.title}</span>
<StatusBadge status={goal.status} />
</div>
{link ? (
<Link
to={link}
className={cn(classes, "no-underline text-inherit")}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
{inner}
</Link>
) : (
<div
className={classes}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect?.(goal)}
>
{inner}
</div>
)}
{hasChildren && expanded && (
<div>
{children.map((child) => (
@@ -58,6 +81,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
children={allGoals.filter((g) => g.parentId === child.id)}
allGoals={allGoals}
depth={depth + 1}
goalLink={goalLink}
onSelect={onSelect}
/>
))}
@@ -67,7 +91,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
);
}
export function GoalTree({ goals, onSelect }: GoalTreeProps) {
export function GoalTree({ goals, goalLink, onSelect }: GoalTreeProps) {
const roots = goals.filter((g) => !g.parentId);
if (goals.length === 0) {
@@ -83,6 +107,7 @@ export function GoalTree({ goals, onSelect }: GoalTreeProps) {
children={goals.filter((g) => g.parentId === goal.id)}
allGoals={goals}
depth={0}
goalLink={goalLink}
onSelect={onSelect}
/>
))}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import Markdown from "react-markdown";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor } from "./MarkdownEditor";
interface InlineEditorProps {
@@ -138,9 +138,7 @@ export function InlineEditor({
onClick={() => setEditing(true)}
>
{value && multiline ? (
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
<Markdown>{value}</Markdown>
</div>
<MarkdownBody>{value}</MarkdownBody>
) : (
value || placeholder
)}

View File

@@ -14,6 +14,7 @@ import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
interface IssuePropertiesProps {
issue: Issue;
@@ -130,6 +131,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
)}
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
@@ -151,7 +153,13 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{issue.projectId ? (
<span className="text-sm">{projectName(issue.projectId)}</span>
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: projects?.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
/>
<span className="text-sm">{projectName(issue.projectId)}</span>
</>
) : (
<>
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
@@ -160,7 +168,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end">
<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..."
@@ -170,7 +178,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
@@ -187,11 +195,15 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
<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",
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
{p.name}
</button>
))}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { useDialog } from "../context/DialogContext";
import { groupBy } from "../lib/groupBy";
import { formatDate } from "../lib/utils";
@@ -139,7 +139,6 @@ export function IssuesList({
onUpdateIssue,
}: IssuesListProps) {
const { openNewIssue } = useDialog();
const navigate = useNavigate();
const [viewState, setViewState] = useState<IssueViewState>(() => getViewState(viewStateKey));
@@ -202,7 +201,7 @@ export function IssuesList({
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3">
<Button size="sm" onClick={() => openNewIssue(newIssueDefaults())}>
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
<Plus className="h-4 w-4 mr-1" />
New Issue
</Button>
@@ -434,16 +433,14 @@ export function IssuesList({
)}
<CollapsibleContent>
{group.items.map((issue) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
<Link
key={issue.id}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
>
{/* Spacer matching caret width so status icon aligns with group title */}
<div className="w-3.5 shrink-0" />
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
@@ -473,7 +470,7 @@ export function IssuesList({
{formatDate(issue.createdAt)}
</span>
</div>
</div>
</Link>
))}
</CollapsibleContent>
</Collapsible>

View File

@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen } from "lucide-react";
import { Outlet } from "react-router-dom";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { SidebarNavItem } from "./SidebarNavItem";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@@ -15,31 +19,50 @@ import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { healthApi } from "../api/health";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading } = useCompany();
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
const onboardingTriggered = useRef(false);
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
});
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
if (health?.deploymentMode === "authenticated") return;
if (companies.length === 0) {
onboardingTriggered.current = true;
openOnboarding();
}
}, [companies, companiesLoading, openOnboarding]);
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
const togglePanel = useCallback(() => {
if (panelContent) closePanel();
}, [panelContent, closePanel]);
// Cmd+1..9 to switch companies
const switchCompany = useCallback(
(index: number) => {
if (index < companies.length) {
setSelectedCompanyId(companies[index]!.id);
}
},
[companies, setSelectedCompanyId],
);
useKeyboardShortcuts({
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onSwitchCompany: switchCompany,
});
return (
@@ -52,24 +75,40 @@ export function Layout() {
/>
)}
{/* Sidebar */}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-60 transition-transform duration-200 ease-in-out",
"fixed inset-y-0 left-0 z-50 flex transition-transform duration-200 ease-in-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<Sidebar />
<div className="flex flex-col h-full">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<Sidebar />
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
</div>
</div>
</div>
) : (
<div
className={cn(
"shrink-0 h-full overflow-hidden transition-all duration-200 ease-in-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
<div className="flex flex-col shrink-0 h-full">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-all duration-200 ease-in-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
</div>
</div>
)}

View File

@@ -0,0 +1,21 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
interface MarkdownBodyProps {
children: string;
className?: string;
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5",
className,
)}
>
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
interface MetricCardProps {
@@ -7,25 +8,22 @@ interface MetricCardProps {
value: string | number;
label: string;
description?: ReactNode;
to?: string;
onClick?: () => void;
}
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
return (
export function MetricCard({ icon: Icon, value, label, description, to, onClick }: MetricCardProps) {
const isClickable = !!(to || onClick);
const inner = (
<Card>
<CardContent className="p-3 sm:p-4">
<div className="flex gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<p
className={`text-lg sm:text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
onClick={onClick}
>
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
{value}
</p>
<p
className={`text-xs sm:text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
onClick={onClick}
>
<p className={`text-xs sm:text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
{label}
</p>
{description && (
@@ -39,4 +37,22 @@ export function MetricCard({ icon: Icon, value, label, description, onClick }: M
</CardContent>
</Card>
);
if (to) {
return (
<Link to={to} className="no-underline text-inherit" onClick={onClick}>
{inner}
</Link>
);
}
if (onClick) {
return (
<div className="cursor-pointer" onClick={onClick}>
{inner}
</div>
);
}
return inner;
}

View File

@@ -27,6 +27,7 @@ import { roleLabels } from "./agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { AgentIcon } from "./AgentIconPicker";
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent } = useDialog();
@@ -163,9 +164,9 @@ export function NewAgentDialog() {
<div className="overflow-y-auto max-h-[70vh]">
{/* Name */}
<div className="px-4 pt-3">
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Agent name"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -225,13 +226,17 @@ export function NewAgentDialog() {
)}
disabled={isFirstAgent}
>
<User className="h-3 w-3 text-muted-foreground" />
{currentReportsTo
? `Reports to ${currentReportsTo.name}`
: isFirstAgent
? "Reports to: N/A (CEO)"
: "Reports to..."
}
{currentReportsTo ? (
<>
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
{`Reports to ${currentReportsTo.name}`}
</>
) : (
<>
<User className="h-3 w-3 text-muted-foreground" />
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
@@ -253,6 +258,7 @@ export function NewAgentDialog() {
)}
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
</button>

View File

@@ -151,9 +151,9 @@ export function NewGoalDialog() {
</div>
{/* Title */}
<div className="px-4 pt-3">
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Goal title"
value={title}
onChange={(e) => setTitle(e.target.value)}

View File

@@ -34,6 +34,7 @@ import {
} from "lucide-react";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
import { AgentIcon } from "./AgentIconPicker";
import type { Project, Agent } from "@paperclip/shared";
const DRAFT_KEY = "paperclip:issue-draft";
@@ -373,8 +374,17 @@ export function NewIssueDialog() {
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<User className="h-3 w-3 text-muted-foreground" />
{currentAssignee ? currentAssignee.name : "Assignee"}
{currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3 w-3 text-muted-foreground" />
{currentAssignee.name}
</>
) : (
<>
<User className="h-3 w-3 text-muted-foreground" />
Assignee
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="start">
@@ -410,6 +420,7 @@ export function NewIssueDialog() {
)}
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
@@ -420,14 +431,26 @@ export function NewIssueDialog() {
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<Hexagon className="h-3 w-3 text-muted-foreground" />
{currentProject ? currentProject.name : "Project"}
{currentProject ? (
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
/>
{currentProject.name}
</>
) : (
<>
<Hexagon className="h-3 w-3 text-muted-foreground" />
Project
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!projectId && "bg-accent"
)}
onClick={() => { setProjectId(""); setProjectOpen(false); }}
@@ -438,11 +461,15 @@ export function NewIssueDialog() {
<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",
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === projectId && "bg-accent"
)}
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
{p.name}
</button>
))}

View File

@@ -24,6 +24,7 @@ import {
Plus,
X,
} from "lucide-react";
import { PROJECT_COLORS } from "@paperclip/shared";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
@@ -89,6 +90,7 @@ export function NewProjectDialog() {
name: name.trim(),
description: description.trim() || undefined,
status,
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
@@ -151,9 +153,9 @@ export function NewProjectDialog() {
</div>
{/* Name */}
<div className="px-4 pt-3">
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { AgentIcon } from "./AgentIconPicker";
@@ -15,6 +16,27 @@ import {
} from "@/components/ui/collapsible";
import type { Agent } from "@paperclip/shared";
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
function sortByHierarchy(agents: Agent[]): Agent[] {
const byId = new Map(agents.map((a) => [a.id, a]));
const childrenOf = new Map<string | null, Agent[]>();
for (const a of agents) {
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const sorted: Agent[] = [];
const queue = childrenOf.get(null) ?? [];
while (queue.length > 0) {
const agent = queue.shift()!;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function SidebarAgents() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
@@ -27,9 +49,27 @@ export function SidebarAgents() {
enabled: !!selectedCompanyId,
});
const visibleAgents = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 10_000,
});
const liveCountByAgent = useMemo(() => {
const counts = new Map<string, number>();
for (const run of liveRuns ?? []) {
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
}
return counts;
}, [liveRuns]);
const visibleAgents = useMemo(() => {
const filtered = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
);
return sortByHierarchy(filtered);
}, [agents]);
const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
const activeAgentId = agentMatch?.[1] ?? null;
@@ -54,24 +94,38 @@ export function SidebarAgents() {
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleAgents.map((agent: Agent) => (
<NavLink
key={agent.id}
to={`/agents/${agent.id}`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeAgentId === agent.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{agent.name}</span>
</NavLink>
))}
{visibleAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<NavLink
key={agent.id}
to={`/agents/${agent.id}`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeAgentId === agent.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{agent.name}</span>
{runCount > 0 && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<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">
{runCount} live
</span>
</span>
)}
</NavLink>
);
})}
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -11,6 +11,7 @@ interface SidebarNavItemProps {
badge?: number;
badgeTone?: "default" | "danger";
alert?: boolean;
liveCount?: number;
}
export function SidebarNavItem({
@@ -21,6 +22,7 @@ export function SidebarNavItem({
badge,
badgeTone = "default",
alert = false,
liveCount,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen } = useSidebar();
@@ -45,6 +47,15 @@ export function SidebarNavItem({
)}
</span>
<span className="flex-1 truncate">{label}</span>
{liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5">
<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">{liveCount} live</span>
</span>
)}
{badge != null && badge > 0 && (
<span
className={cn(

View File

@@ -70,7 +70,7 @@ export function SidebarProjects() {
{visibleProjects.map((project: Project) => (
<NavLink
key={project.id}
to={`/projects/${project.id}`}
to={`/projects/${project.id}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}

View File

@@ -10,6 +10,7 @@ import {
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Company } from "@paperclip/shared";
import { companiesApi } from "../api/companies";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
interface CompanyContextValue {
@@ -39,7 +40,17 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
const { data: companies = [], isLoading, error } = useQuery({
queryKey: queryKeys.companies.all,
queryFn: () => companiesApi.list(),
queryFn: async () => {
try {
return await companiesApi.list();
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
return [];
}
throw err;
}
},
retry: false,
});
// Auto-select first company when list loads

View File

@@ -75,16 +75,49 @@ interface IssueToastContext {
href: string;
}
function resolveIssueQueryRefs(
queryClient: QueryClient,
companyId: string,
issueId: string,
details: Record<string, unknown> | null,
): string[] {
const refs = new Set<string>([issueId]);
const detailIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId));
const listIssues = queryClient.getQueryData<Issue[]>(queryKeys.issues.list(companyId));
const detailsIdentifier =
readString(details?.identifier) ??
readString(details?.issueIdentifier);
if (detailsIdentifier) refs.add(detailsIdentifier);
if (detailIssue?.id) refs.add(detailIssue.id);
if (detailIssue?.identifier) refs.add(detailIssue.identifier);
const listIssue = listIssues?.find((issue) => {
if (issue.id === issueId) return true;
if (issue.identifier && issue.identifier === issueId) return true;
if (detailsIdentifier && issue.identifier === detailsIdentifier) return true;
return false;
});
if (listIssue?.id) refs.add(listIssue.id);
if (listIssue?.identifier) refs.add(listIssue.identifier);
return Array.from(refs);
}
function resolveIssueToastContext(
queryClient: QueryClient,
companyId: string,
issueId: string,
details: Record<string, unknown> | null,
): IssueToastContext {
const detailIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId));
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, issueId, details);
const detailIssue = issueRefs
.map((ref) => queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)))
.find((issue): issue is Issue => !!issue);
const listIssue = queryClient
.getQueryData<Issue[]>(queryKeys.issues.list(companyId))
?.find((issue) => issue.id === issueId);
?.find((issue) => issueRefs.some((ref) => issue.id === ref || issue.identifier === ref));
const cachedIssue = detailIssue ?? listIssue ?? null;
const ref =
readString(details?.identifier) ??
@@ -290,12 +323,16 @@ function invalidateActivityQueries(
if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (entityId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) });
const details = readRecord(payload.details);
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
for (const ref of issueRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
}
}
return;
}

View File

@@ -4,9 +4,10 @@ interface ShortcutHandlers {
onNewIssue?: () => void;
onToggleSidebar?: () => void;
onTogglePanel?: () => void;
onSwitchCompany?: (index: number) => void;
}
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs
@@ -15,6 +16,13 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
return;
}
// Cmd+1..9 → Switch company
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
e.preventDefault();
onSwitchCompany?.(parseInt(e.key, 10) - 1);
return;
}
// C → New Issue
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
@@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]);
}

View File

@@ -14,6 +14,8 @@ export const queryKeys = {
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const,
detail: (id: string) => ["issues", "detail", id] as const,
comments: (issueId: string) => ["issues", "comments", issueId] as const,
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
@@ -38,6 +40,15 @@ export const queryKeys = {
comments: (approvalId: string) => ["approvals", "comments", approvalId] as const,
issues: (approvalId: string) => ["approvals", "issues", approvalId] as const,
},
access: {
joinRequests: (companyId: string, status: string = "pending_approval") =>
["access", "join-requests", companyId, status] as const,
invite: (token: string) => ["access", "invite", token] as const,
},
auth: {
session: ["auth", "session"] as const,
},
health: ["health"] as const,
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,
providers: (companyId: string) => ["secret-providers", companyId] as const,

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 && (