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:
141
ui/src/App.tsx
141
ui/src/App.tsx
@@ -1,5 +1,8 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { authApi } from "./api/auth";
|
||||
import { healthApi } from "./api/health";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
@@ -17,43 +20,113 @@ import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
import { AuthPage } from "./pages/Auth";
|
||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||
import { queryKeys } from "./lib/queryKeys";
|
||||
|
||||
function BootstrapPendingPage() {
|
||||
return (
|
||||
<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
52
ui/src/api/access.ts
Normal 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`, {}),
|
||||
};
|
||||
@@ -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
74
ui/src/api/auth.ts
Normal 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", {});
|
||||
},
|
||||
};
|
||||
@@ -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
20
ui/src/api/health.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & Configuration</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions & 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
267
ui/src/components/CompanyRail.tsx
Normal file
267
ui/src/components/CompanyRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
21
ui/src/components/MarkdownBody.tsx
Normal file
21
ui/src/components/MarkdownBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -191,7 +191,7 @@ export function Agents() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" onClick={openNewAgent}>
|
||||
<Button size="sm" variant="outline" onClick={openNewAgent}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New Agent
|
||||
</Button>
|
||||
@@ -223,7 +223,7 @@ export function Agents() {
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
to={`/agents/${agent.id}`}
|
||||
leading={
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
@@ -251,7 +251,6 @@ export function Agents() {
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={agent.status} />
|
||||
@@ -263,7 +262,6 @@ export function Agents() {
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
@@ -294,7 +292,7 @@ export function Agents() {
|
||||
{effectiveView === "org" && filteredOrg.length > 0 && (
|
||||
<div className="border border-border py-1">
|
||||
{filteredOrg.map((node) => (
|
||||
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
||||
<OrgTreeNode key={node.id} node={node} depth={0} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -317,13 +315,11 @@ export function Agents() {
|
||||
function OrgTreeNode({
|
||||
node,
|
||||
depth,
|
||||
navigate,
|
||||
agentMap,
|
||||
liveRunByAgent,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
depth: number;
|
||||
navigate: (path: string) => void;
|
||||
agentMap: Map<string, Agent>;
|
||||
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
||||
}) {
|
||||
@@ -344,9 +340,9 @@ function OrgTreeNode({
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<button
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left"
|
||||
onClick={() => navigate(`/agents/${node.id}`)}
|
||||
<Link
|
||||
to={`/agents/${node.id}`}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit"
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
||||
@@ -365,7 +361,6 @@ function OrgTreeNode({
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={node.status} />
|
||||
@@ -377,7 +372,6 @@ function OrgTreeNode({
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{agent && (
|
||||
@@ -395,11 +389,11 @@ function OrgTreeNode({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
{node.reports && node.reports.length > 0 && (
|
||||
<div className="border-l border-border/50 ml-4">
|
||||
{node.reports.map((child) => (
|
||||
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
||||
<OrgTreeNode key={child.id} node={child} depth={depth + 1} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -411,20 +405,16 @@ function LiveRunIndicator({
|
||||
agentId,
|
||||
runId,
|
||||
liveCount,
|
||||
navigate,
|
||||
}: {
|
||||
agentId: string;
|
||||
runId: string;
|
||||
liveCount: number;
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/${agentId}/runs/${runId}`);
|
||||
}}
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${runId}`}
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
@@ -433,6 +423,6 @@ function LiveRunIndicator({
|
||||
<span className="text-[11px] font-medium text-blue-400">
|
||||
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
||||
import type { ApprovalComment } from "@paperclip/shared";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
|
||||
export function ApprovalDetail() {
|
||||
const { approvalId } = useParams<{ approvalId: string }>();
|
||||
@@ -329,7 +330,7 @@ export function ApprovalDetail() {
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
141
ui/src/pages/Auth.tsx
Normal file
141
ui/src/pages/Auth.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
|
||||
export function AuthPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [mode, setMode] = useState<AuthMode>("sign_in");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams]);
|
||||
const { data: session, isLoading: isSessionLoading } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
navigate(nextPath, { replace: true });
|
||||
}
|
||||
}, [session, navigate, nextPath]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (mode === "sign_in") {
|
||||
await authApi.signInEmail({ email: email.trim(), password });
|
||||
return;
|
||||
}
|
||||
await authApi.signUpEmail({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setError(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
navigate(nextPath, { replace: true });
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
},
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
email.trim().length > 0 &&
|
||||
password.trim().length >= 8 &&
|
||||
(mode === "sign_in" || name.trim().length > 0);
|
||||
|
||||
if (isSessionLoading) {
|
||||
return <div className="mx-auto max-w-md py-16 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{mode === "sign_in"
|
||||
? "Use your email and password to access this instance."
|
||||
: "Create an account for this instance. Email confirmation is not required in v1."}
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-5 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
{mode === "sign_up" && (
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Email</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Password</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending} className="w-full">
|
||||
{mutation.isPending
|
||||
? "Working..."
|
||||
: mode === "sign_in"
|
||||
? "Sign In"
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-foreground underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "sign_in" ? "sign_up" : "sign_in");
|
||||
}}
|
||||
>
|
||||
{mode === "sign_in" ? "Create one" : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings } from "lucide-react";
|
||||
@@ -11,6 +12,10 @@ export function CompanySettings() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [joinType, setJoinType] = useState<"human" | "agent" | "both">("both");
|
||||
const [expiresInHours, setExpiresInHours] = useState(72);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
|
||||
const settingsMutation = useMutation({
|
||||
mutationFn: (requireApproval: boolean) =>
|
||||
@@ -22,6 +27,31 @@ export function CompanySettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: joinType,
|
||||
expiresInHours,
|
||||
}),
|
||||
onSuccess: (invite) => {
|
||||
setInviteError(null);
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
const absoluteUrl = invite.inviteUrl.startsWith("http")
|
||||
? invite.inviteUrl
|
||||
: `${base}${invite.inviteUrl}`;
|
||||
setInviteLink(absoluteUrl);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
||||
},
|
||||
});
|
||||
|
||||
const inviteExpiryHint = useMemo(() => {
|
||||
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000);
|
||||
return expiresAt.toLocaleString();
|
||||
}, [expiresInHours]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
@@ -75,6 +105,63 @@ export function CompanySettings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Invites
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Allowed join type</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-2 text-sm"
|
||||
value={joinType}
|
||||
onChange={(event) => setJoinType(event.target.value as "human" | "agent" | "both")}
|
||||
>
|
||||
<option value="both">Human or agent</option>
|
||||
<option value="human">Human only</option>
|
||||
<option value="agent">Agent only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Expires in hours</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-2 text-sm"
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={expiresInHours}
|
||||
onChange={(event) => setExpiresInHours(Math.max(1, Math.min(720, Number(event.target.value) || 72)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Invite will expire around {inviteExpiryHint}.</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}>
|
||||
{inviteMutation.isPending ? "Creating..." : "Create invite link"}
|
||||
</Button>
|
||||
{inviteLink && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
|
||||
{inviteLink && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||
<div className="text-xs text-muted-foreground">Share link</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{inviteLink}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
@@ -31,7 +31,6 @@ export function Dashboard() {
|
||||
const { selectedCompanyId, selectedCompany, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
const hydratedActivityRef = useRef(false);
|
||||
@@ -180,14 +179,12 @@ export function Dashboard() {
|
||||
icon={Bot}
|
||||
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
||||
label="Agents Enabled"
|
||||
onClick={() => navigate("/agents")}
|
||||
to="/agents"
|
||||
description={
|
||||
<span>
|
||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.running} running</span>
|
||||
{", "}
|
||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
|
||||
{", "}
|
||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
|
||||
{data.agents.running} running{", "}
|
||||
{data.agents.paused} paused{", "}
|
||||
{data.agents.error} errors
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -195,12 +192,11 @@ export function Dashboard() {
|
||||
icon={CircleDot}
|
||||
value={data.tasks.inProgress}
|
||||
label="Tasks In Progress"
|
||||
onClick={() => navigate("/issues")}
|
||||
to="/issues"
|
||||
description={
|
||||
<span>
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.open} open</span>
|
||||
{", "}
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.blocked} blocked</span>
|
||||
{data.tasks.open} open{", "}
|
||||
{data.tasks.blocked} blocked
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -208,9 +204,9 @@ export function Dashboard() {
|
||||
icon={DollarSign}
|
||||
value={formatCents(data.costs.monthSpendCents)}
|
||||
label="Month Spend"
|
||||
onClick={() => navigate("/costs")}
|
||||
to="/costs"
|
||||
description={
|
||||
<span className="cursor-pointer" onClick={() => navigate("/costs")}>
|
||||
<span>
|
||||
{data.costs.monthBudgetCents > 0
|
||||
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
|
||||
: "Unlimited budget"}
|
||||
@@ -221,9 +217,9 @@ export function Dashboard() {
|
||||
icon={ShieldCheck}
|
||||
value={data.pendingApprovals}
|
||||
label="Pending Approvals"
|
||||
onClick={() => navigate("/approvals")}
|
||||
to="/approvals"
|
||||
description={
|
||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>
|
||||
<span>
|
||||
{data.staleTasks} stale tasks
|
||||
</span>
|
||||
}
|
||||
@@ -263,10 +259,10 @@ export function Dashboard() {
|
||||
) : (
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{recentIssues.slice(0, 10).map((issue) => (
|
||||
<div
|
||||
<Link
|
||||
key={issue.id}
|
||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
@@ -288,7 +284,7 @@ export function Dashboard() {
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -25,42 +25,54 @@ export function GoalDetail() {
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: goal, isLoading, error } = useQuery({
|
||||
const {
|
||||
data: goal,
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.goals.detail(goalId!),
|
||||
queryFn: () => goalsApi.get(goalId!),
|
||||
enabled: !!goalId,
|
||||
enabled: !!goalId
|
||||
});
|
||||
|
||||
const { data: allGoals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
enabled: !!selectedCompanyId
|
||||
});
|
||||
|
||||
const { data: allProjects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
enabled: !!selectedCompanyId
|
||||
});
|
||||
|
||||
const updateGoal = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => goalsApi.update(goalId!, data),
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
goalsApi.update(goalId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(goalId!) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.detail(goalId!)
|
||||
});
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId)
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, `goals/${goalId ?? "draft"}`);
|
||||
},
|
||||
return assetsApi.uploadImage(
|
||||
selectedCompanyId,
|
||||
file,
|
||||
`goals/${goalId ?? "draft"}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
|
||||
@@ -74,20 +86,24 @@ export function GoalDetail() {
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Goals", href: "/goals" },
|
||||
{ label: goal?.title ?? goalId ?? "Goal" },
|
||||
{ label: goal?.title ?? goalId ?? "Goal" }
|
||||
]);
|
||||
}, [setBreadcrumbs, goal, goalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (goal) {
|
||||
openPanel(
|
||||
<GoalProperties goal={goal} onUpdate={(data) => updateGoal.mutate(data)} />
|
||||
<GoalProperties
|
||||
goal={goal}
|
||||
onUpdate={(data) => updateGoal.mutate(data)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return () => closePanel();
|
||||
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (isLoading)
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!goal) return null;
|
||||
|
||||
@@ -95,7 +111,9 @@ export function GoalDetail() {
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase text-muted-foreground">{goal.level}</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{goal.level}
|
||||
</span>
|
||||
<StatusBadge status={goal.status} />
|
||||
</div>
|
||||
|
||||
@@ -122,13 +140,21 @@ export function GoalDetail() {
|
||||
|
||||
<Tabs defaultValue="children">
|
||||
<TabsList>
|
||||
<TabsTrigger value="children">Sub-Goals ({childGoals.length})</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projects ({linkedProjects.length})</TabsTrigger>
|
||||
<TabsTrigger value="children">
|
||||
Sub-Goals ({childGoals.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="projects">
|
||||
Projects ({linkedProjects.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="children" className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewGoal({ parentId: goalId })}>
|
||||
<div className="flex items-center justify-start">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openNewGoal({ parentId: goalId })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Sub Goal
|
||||
</Button>
|
||||
@@ -136,10 +162,7 @@ export function GoalDetail() {
|
||||
{childGoals.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No sub-goals.</p>
|
||||
) : (
|
||||
<GoalTree
|
||||
goals={childGoals}
|
||||
onSelect={(g) => navigate(`/goals/${g.id}`)}
|
||||
/>
|
||||
<GoalTree goals={childGoals} goalLink={(g) => `/goals/${g.id}`} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -153,7 +176,7 @@ export function GoalDetail() {
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
to={`/projects/${project.id}`}
|
||||
trailing={<StatusBadge status={project.status} />}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -15,7 +14,6 @@ export function Goals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Goals" }]);
|
||||
@@ -47,13 +45,13 @@ export function Goals() {
|
||||
|
||||
{goals && goals.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-start">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewGoal()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New Goal
|
||||
</Button>
|
||||
</div>
|
||||
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
|
||||
<GoalTree goals={goals} goalLink={(goal) => `/goals/${goal.id}`} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { accessApi } from "../api/access";
|
||||
import { ApiError } from "../api/client";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -17,19 +19,39 @@ import { StatusBadge } from "../components/StatusBadge";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "../components/Identity";
|
||||
import type { HeartbeatRun, Issue } from "@paperclip/shared";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclip/shared";
|
||||
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
type InboxTab = "new" | "all";
|
||||
type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts"
|
||||
| "stale_work";
|
||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
type SectionKey = "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work";
|
||||
|
||||
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||
timer: "Scheduled",
|
||||
@@ -44,12 +66,9 @@ function getStaleIssues(issues: Issue[]): Issue[] {
|
||||
.filter(
|
||||
(i) =>
|
||||
["in_progress", "todo"].includes(i.status) &&
|
||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
|
||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
);
|
||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
||||
}
|
||||
|
||||
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||
@@ -64,9 +83,7 @@ function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByAgent.values()).filter((run) =>
|
||||
FAILED_RUN_STATUSES.has(run.status),
|
||||
);
|
||||
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
@@ -76,11 +93,7 @@ function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
function runFailureMessage(run: HeartbeatRun): string {
|
||||
return (
|
||||
firstNonEmptyLine(run.error) ??
|
||||
firstNonEmptyLine(run.stderrExcerpt) ??
|
||||
"Run exited with an error."
|
||||
);
|
||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||
}
|
||||
|
||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
@@ -100,8 +113,14 @@ export function Inbox() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
||||
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -113,25 +132,48 @@ export function Inbox() {
|
||||
setBreadcrumbs([{ label: "Inbox" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({
|
||||
const {
|
||||
data: approvals,
|
||||
isLoading: isApprovalsLoading,
|
||||
error: approvalsError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
||||
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: dashboard } = useQuery({
|
||||
const {
|
||||
data: joinRequests = [],
|
||||
isLoading: isJoinRequestsLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId!),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await accessApi.listJoinRequests(selectedCompanyId!, "pending_approval");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 403 || err.status === 401)) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: dashboard, isLoading: isDashboardLoading } = useQuery({
|
||||
queryKey: queryKeys.dashboard(selectedCompanyId!),
|
||||
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: issues } = useQuery({
|
||||
const { data: issues, isLoading: isIssuesLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: heartbeatRuns } = useQuery({
|
||||
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
@@ -156,6 +198,28 @@ export function Inbox() {
|
||||
[heartbeatRuns],
|
||||
);
|
||||
|
||||
const allApprovals = useMemo(
|
||||
() =>
|
||||
[...(approvals ?? [])].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[approvals],
|
||||
);
|
||||
|
||||
const actionableApprovals = useMemo(
|
||||
() => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)),
|
||||
[allApprovals],
|
||||
);
|
||||
|
||||
const filteredAllApprovals = useMemo(() => {
|
||||
if (allApprovalFilter === "all") return allApprovals;
|
||||
|
||||
return allApprovals.filter((approval) => {
|
||||
const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||
return allApprovalFilter === "actionable" ? isActionable : !isActionable;
|
||||
});
|
||||
}, [allApprovals, allApprovalFilter]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agentById.get(id) ?? null;
|
||||
@@ -164,6 +228,7 @@ export function Inbox() {
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
onSuccess: (_approval, id) => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
@@ -175,6 +240,7 @@ export function Inbox() {
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.reject(id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -182,67 +248,251 @@ export function Inbox() {
|
||||
},
|
||||
});
|
||||
|
||||
const approveJoinMutation = useMutation({
|
||||
mutationFn: (joinRequest: JoinRequest) =>
|
||||
accessApi.approveJoinRequest(selectedCompanyId!, joinRequest.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve join request");
|
||||
},
|
||||
});
|
||||
|
||||
const rejectJoinMutation = useMutation({
|
||||
mutationFn: (joinRequest: JoinRequest) =>
|
||||
accessApi.rejectJoinRequest(selectedCompanyId!, joinRequest.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reject join request");
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
|
||||
const actionableApprovals = (approvals ?? []).filter(
|
||||
(approval) => approval.status === "pending" || approval.status === "revision_requested",
|
||||
);
|
||||
const hasActionableApprovals = actionableApprovals.length > 0;
|
||||
const hasRunFailures = failedRuns.length > 0;
|
||||
const showAggregateAgentError =
|
||||
!!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
|
||||
const hasAlerts =
|
||||
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
|
||||
const showBudgetAlert =
|
||||
!!dashboard &&
|
||||
(showAggregateAgentError || (dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthUtilizationPercent >= 80));
|
||||
dashboard.costs.monthBudgetCents > 0 &&
|
||||
dashboard.costs.monthUtilizationPercent >= 80;
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasContent = hasActionableApprovals || hasRunFailures || hasAlerts || hasStale;
|
||||
const hasJoinRequests = joinRequests.length > 0;
|
||||
|
||||
const newItemCount =
|
||||
joinRequests.length +
|
||||
actionableApprovals.length +
|
||||
failedRuns.length +
|
||||
staleIssues.length +
|
||||
(showAggregateAgentError ? 1 : 0) +
|
||||
(showBudgetAlert ? 1 : 0);
|
||||
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
||||
|
||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||
const showJoinRequestsSection =
|
||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
||||
const showApprovalsSection =
|
||||
tab === "new"
|
||||
? actionableApprovals.length > 0
|
||||
: showApprovalsCategory && filteredAllApprovals.length > 0;
|
||||
const showFailedRunsSection =
|
||||
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
||||
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
||||
|
||||
const visibleSections = [
|
||||
showApprovalsSection ? "approvals" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showStaleSection ? "stale_work" : null,
|
||||
].filter((key): key is SectionKey => key !== null);
|
||||
|
||||
const isLoading =
|
||||
isJoinRequestsLoading ||
|
||||
isApprovalsLoading ||
|
||||
isDashboardLoading ||
|
||||
isIssuesLoading ||
|
||||
isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isApprovalsLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "new",
|
||||
label: (
|
||||
<>
|
||||
New
|
||||
{newItemCount > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-500">
|
||||
{newItemCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
<SelectItem value="stale_work">Stale work</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Approval status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All approval statuses</SelectItem>
|
||||
<SelectItem value="actionable">Needs action</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
||||
{!isApprovalsLoading && !hasContent && (
|
||||
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
||||
{!isLoading && visibleSections.length === 0 && (
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
message={tab === "new" ? "You're all caught up!" : "No inbox items match these filters."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pending Approvals */}
|
||||
{hasActionableApprovals && (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Approvals
|
||||
</h3>
|
||||
<button
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => navigate("/approvals")}
|
||||
>
|
||||
See all approvals <ExternalLink className="ml-0.5 inline h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{actionableApprovals.map((approval) => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={() => approveMutation.mutate(approval.id)}
|
||||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
onOpen={() => navigate(`/approvals/${approval.id}`)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed Runs */}
|
||||
{hasRunFailures && (
|
||||
{showApprovalsSection && (
|
||||
<>
|
||||
{hasActionableApprovals && <Separator />}
|
||||
{showSeparatorBefore("approvals") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{approvalsToRender.map((approval) => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
requesterAgent={
|
||||
approval.requestedByAgentId
|
||||
? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null
|
||||
: null
|
||||
}
|
||||
onApprove={() => approveMutation.mutate(approval.id)}
|
||||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showJoinRequestsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("join_requests") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Join Requests
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{joinRequests.map((joinRequest) => (
|
||||
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{joinRequest.requestType === "human"
|
||||
? "Human join request"
|
||||
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
|
||||
</p>
|
||||
{joinRequest.requestEmailSnapshot && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
email: {joinRequest.requestEmailSnapshot}
|
||||
</p>
|
||||
)}
|
||||
{joinRequest.adapterType && (
|
||||
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
onClick={() => rejectJoinMutation.mutate(joinRequest)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
onClick={() => approveJoinMutation.mutate(joinRequest)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showFailedRunsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("failed_runs") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Failed Runs
|
||||
@@ -268,9 +518,11 @@ export function Inbox() {
|
||||
<span className="rounded-md bg-red-500/20 p-1.5">
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
</span>
|
||||
{linkedAgentName
|
||||
? <Identity name={linkedAgentName} size="sm" />
|
||||
: <span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>}
|
||||
{linkedAgentName ? (
|
||||
<Identity name={linkedAgentName} size="sm" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
|
||||
)}
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
@@ -282,10 +534,12 @@ export function Inbox() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5"
|
||||
onClick={() => navigate(`/agents/${run.agentId}/runs/${run.id}`)}
|
||||
asChild
|
||||
>
|
||||
Open run
|
||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
|
||||
Open run
|
||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -296,13 +550,12 @@ export function Inbox() {
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
|
||||
{issue ? (
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="truncate text-muted-foreground transition-colors hover:text-foreground no-underline"
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
|
||||
@@ -318,61 +571,57 @@ export function Inbox() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
{hasAlerts && (
|
||||
{showAlertsSection && (
|
||||
<>
|
||||
{(hasActionableApprovals || hasRunFailures) && <Separator />}
|
||||
{showSeparatorBefore("alerts") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Alerts
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{showAggregateAgentError && (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
onClick={() => navigate("/agents")}
|
||||
<Link
|
||||
to="/agents"
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
|
||||
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{dashboard!.costs.monthBudgetCents > 0 && dashboard!.costs.monthUtilizationPercent >= 80 && (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
onClick={() => navigate("/costs")}
|
||||
{showBudgetAlert && (
|
||||
<Link
|
||||
to="/costs"
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
|
||||
<span className="text-sm">
|
||||
Budget at{" "}
|
||||
<span className="font-medium">
|
||||
{dashboard!.costs.monthUtilizationPercent}%
|
||||
</span>{" "}
|
||||
<span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "}
|
||||
utilization this month
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stale Work */}
|
||||
{hasStale && (
|
||||
{showStaleSection && (
|
||||
<>
|
||||
{(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
|
||||
{showSeparatorBefore("stale_work") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Stale Work
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{staleIssues.map((issue) => (
|
||||
<div
|
||||
<Link
|
||||
key={issue.id}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||
>
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
@@ -381,16 +630,21 @@ export function Inbox() {
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="font-mono text-xs text-muted-foreground">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
{issue.assigneeAgentId &&
|
||||
(() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? (
|
||||
<Identity name={name} size="sm" />
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
updated {timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
236
ui/src/pages/InviteLanding.tsx
Normal file
236
ui/src/pages/InviteLanding.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { healthApi } from "../api/health";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { JoinRequest } from "@paperclip/shared";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function InviteLandingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams();
|
||||
const token = (params.token ?? "").trim();
|
||||
const [joinType, setJoinType] = useState<JoinType>("human");
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [adapterType, setAdapterType] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
});
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: queryKeys.access.invite(token),
|
||||
queryFn: () => accessApi.getInvite(token),
|
||||
enabled: token.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
|
||||
const availableJoinTypes = useMemo(() => {
|
||||
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
||||
if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
|
||||
return [allowedJoinTypes] as JoinType[];
|
||||
}, [invite?.inviteType, allowedJoinTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableJoinTypes.includes(joinType)) {
|
||||
setJoinType(availableJoinTypes[0] ?? "human");
|
||||
}
|
||||
}, [availableJoinTypes, joinType]);
|
||||
|
||||
const requiresAuthForHuman =
|
||||
joinType === "human" &&
|
||||
healthQuery.data?.deploymentMode === "authenticated" &&
|
||||
!sessionQuery.data;
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!invite) throw new Error("Invite not found");
|
||||
if (invite.inviteType === "bootstrap_ceo") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
}
|
||||
if (joinType === "human") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
}
|
||||
return accessApi.acceptInvite(token, {
|
||||
requestType: "agent",
|
||||
agentName: agentName.trim(),
|
||||
adapterType: adapterType.trim() || undefined,
|
||||
capabilities: capabilities.trim() || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
setError(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
const asBootstrap =
|
||||
payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record<string, unknown>);
|
||||
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to accept invite");
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
|
||||
}
|
||||
|
||||
if (inviteQuery.isLoading || healthQuery.isLoading || sessionQuery.isLoading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading invite...</div>;
|
||||
}
|
||||
|
||||
if (inviteQuery.error || !invite) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">Invite not available</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This invite may be expired, revoked, or already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.kind === "bootstrap") {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The first instance admin is now configured. You can continue to the board.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.kind === "join") {
|
||||
const payload = result.payload as JoinRequest;
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">Join request submitted</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Your request is pending admin approval. You will not have access until approved.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Request ID: <span className="font-mono">{payload.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
|
||||
|
||||
{invite.inviteType !== "bootstrap_ceo" && (
|
||||
<div className="mt-5 flex gap-2">
|
||||
{availableJoinTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setJoinType(type)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${
|
||||
joinType === type
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-background text-foreground"
|
||||
}`}
|
||||
>
|
||||
Join as {type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Agent name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={agentName}
|
||||
onChange={(event) => setAgentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Adapter type (optional)</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={adapterType}
|
||||
onChange={(event) => setAdapterType(event.target.value)}
|
||||
placeholder="process"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Capabilities (optional)</span>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
rows={4}
|
||||
value={capabilities}
|
||||
onChange={(event) => setCapabilities(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresAuthForHuman && (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
Sign in or create an account before submitting a human join request.
|
||||
<div className="mt-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}>Sign in / Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button
|
||||
className="mt-5"
|
||||
disabled={
|
||||
acceptMutation.isPending ||
|
||||
(joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && agentName.trim().length === 0) ||
|
||||
requiresAuthForHuman
|
||||
}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending
|
||||
? "Submitting..."
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: "Submit join request"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,8 +122,6 @@ export function IssueDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -412,54 +410,20 @@ export function IssueDetail() {
|
||||
/>
|
||||
<span className="text-xs font-mono text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
<Popover open={projectOpen} onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
{issue.projectId
|
||||
? ((projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8))
|
||||
: <span className="opacity-50">No project</span>
|
||||
}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="start">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search projects..."
|
||||
value={projectSearch}
|
||||
onChange={(e) => setProjectSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { updateIssue.mutate({ projectId: null }); setProjectOpen(false); }}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
{(projects ?? [])
|
||||
.filter((p) => {
|
||||
if (!projectSearch.trim()) return true;
|
||||
return p.name.toLowerCase().includes(projectSearch.toLowerCase());
|
||||
})
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
p.id === issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { updateIssue.mutate({ projectId: p.id }); setProjectOpen(false); }}
|
||||
>
|
||||
<Hexagon className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
{p.name}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5"
|
||||
>
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -591,6 +555,10 @@ export function IssueDetail() {
|
||||
onAdd={async (body, reopen) => {
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
}}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
/>
|
||||
|
||||
{childIssues.length > 0 && (
|
||||
|
||||
@@ -1,69 +1,19 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { CircleDot, Plus } from "lucide-react";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { Identity } from "../components/Identity";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
|
||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
type TabFilter = "all" | "active" | "backlog" | "done" | "recent";
|
||||
|
||||
const issueTabItems = [
|
||||
{ value: "all", label: "All Issues" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
{ value: "done", label: "Done" },
|
||||
{ value: "recent", label: "Recent" },
|
||||
] as const;
|
||||
|
||||
function parseIssueTab(value: string | null): TabFilter {
|
||||
if (value === "all" || value === "active" || value === "backlog" || value === "done" || value === "recent") return value;
|
||||
return "active";
|
||||
}
|
||||
|
||||
function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
|
||||
switch (tab) {
|
||||
case "active":
|
||||
return issues.filter((i) => ["todo", "in_progress", "in_review", "blocked"].includes(i.status));
|
||||
case "backlog":
|
||||
return issues.filter((i) => i.status === "backlog");
|
||||
case "done":
|
||||
return issues.filter((i) => ["done", "cancelled"].includes(i.status));
|
||||
default:
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
export function Issues() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "active";
|
||||
const tab = parseIssueTab(pathSegment);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -104,164 +54,19 @@ export function Issues() {
|
||||
},
|
||||
});
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
||||
}
|
||||
|
||||
const filtered = filterIssues(issues ?? [], tab);
|
||||
const recentSorted = tab === "recent"
|
||||
? [...filtered].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
: null;
|
||||
const grouped = groupBy(filtered, (i) => i.status);
|
||||
const orderedGroups = statusOrder
|
||||
.filter((s) => grouped[s]?.length)
|
||||
.map((s) => ({ status: s, items: grouped[s]! }));
|
||||
|
||||
const setTab = (nextTab: TabFilter) => {
|
||||
navigate(`/issues/${nextTab}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
||||
<PageTabBar items={[...issueTabItems]} value={tab} onValueChange={(v) => setTab(v as TabFilter)} />
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => openNewIssue()}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{issues && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues found."
|
||||
action="Create Issue"
|
||||
onAction={() => openNewIssue()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{recentSorted ? (
|
||||
<div className="border border-border">
|
||||
{recentSorted.map((issue) => (
|
||||
<EntityRow
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
leading={
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(p) => updateIssue.mutate({ id: issue.id, data: { priority: p } })}
|
||||
/>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
</span>
|
||||
)}
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
orderedGroups.map(({ status, items }) => (
|
||||
<div key={status}>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50">
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{items.length}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue({ status })}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border border-border">
|
||||
{items.map((issue) => (
|
||||
<EntityRow
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
leading={
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(p) => updateIssue.mutate({ id: issue.id, data: { priority: p } })}
|
||||
/>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => updateIssue.mutate({ id: issue.id, data: { status: s } })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
</span>
|
||||
)}
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
isLoading={isLoading}
|
||||
error={error as Error | null}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
viewStateKey="paperclip:issues-view"
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -15,7 +14,6 @@ import { ListTodo } from "lucide-react";
|
||||
export function MyIssues() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "My Issues" }]);
|
||||
@@ -52,7 +50,7 @@ export function MyIssues() {
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}}
|
||||
leading={
|
||||
<>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
|
||||
@@ -100,7 +100,7 @@ function ColorPicker({
|
||||
aria-label="Change project color"
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50">
|
||||
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{PROJECT_COLORS.map((color) => (
|
||||
<button
|
||||
@@ -252,11 +252,13 @@ export function ProjectDetail() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
currentColor={project.color ?? "#6366f1"}
|
||||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-7 flex items-center">
|
||||
<ColorPicker
|
||||
currentColor={project.color ?? "#6366f1"}
|
||||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
</div>
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
onSave={(name) => updateProject.mutate({ name })}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -17,7 +16,6 @@ export function Projects() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Projects" }]);
|
||||
@@ -36,7 +34,7 @@ export function Projects() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" onClick={openNewProject}>
|
||||
<Button size="sm" variant="outline" onClick={openNewProject}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Project
|
||||
</Button>
|
||||
@@ -61,7 +59,7 @@ export function Projects() {
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
to={`/projects/${project.id}`}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{project.targetDate && (
|
||||
|
||||
Reference in New Issue
Block a user