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 { Layout } from "./components/Layout";
|
||||||
|
import { authApi } from "./api/auth";
|
||||||
|
import { healthApi } from "./api/health";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Companies } from "./pages/Companies";
|
import { Companies } from "./pages/Companies";
|
||||||
import { Agents } from "./pages/Agents";
|
import { Agents } from "./pages/Agents";
|
||||||
@@ -17,43 +20,113 @@ import { Activity } from "./pages/Activity";
|
|||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route path="auth" element={<AuthPage />} />
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="companies" element={<Companies />} />
|
<Route element={<CloudAccessGate />}>
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route element={<Layout />}>
|
||||||
<Route path="org" element={<Navigate to="/agents/all" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="agents/all" element={<Agents />} />
|
<Route path="companies" element={<Companies />} />
|
||||||
<Route path="agents/active" element={<Agents />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="agents/paused" element={<Agents />} />
|
<Route path="org" element={<Navigate to="/agents/all" replace />} />
|
||||||
<Route path="agents/error" element={<Agents />} />
|
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
<Route path="agents/all" element={<Agents />} />
|
||||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
<Route path="agents/active" element={<Agents />} />
|
||||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
<Route path="agents/paused" element={<Agents />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
<Route path="agents/error" element={<Agents />} />
|
||||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
<Route path="issues" element={<Navigate to="/issues/active" replace />} />
|
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||||
<Route path="issues/all" element={<Issues />} />
|
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||||
<Route path="issues/active" element={<Issues />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="issues/backlog" element={<Issues />} />
|
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||||
<Route path="issues/done" element={<Issues />} />
|
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||||
<Route path="issues/recent" element={<Issues />} />
|
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="approvals/pending" element={<Approvals />} />
|
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="approvals/all" element={<Approvals />} />
|
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||||
<Route path="activity" element={<Activity />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
<Route path="inbox" element={<Inbox />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<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>
|
</Route>
|
||||||
</Routes>
|
</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;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClaudeLoginResult {
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
timedOut: boolean;
|
||||||
|
loginUrl: string | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrgNode {
|
export interface OrgNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,4 +96,5 @@ export const agentsApi = {
|
|||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
},
|
},
|
||||||
) => api.post<HeartbeatRun | { status: "skipped" }>(`/agents/${id}/wakeup`, data),
|
) => 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";
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const headers = new Headers(init?.headers ?? undefined);
|
const headers = new Headers(init?.headers ?? undefined);
|
||||||
const body = init?.body;
|
const body = init?.body;
|
||||||
@@ -9,11 +21,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers,
|
headers,
|
||||||
|
credentials: "include",
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => null);
|
const errorBody = await res.json().catch(() => null);
|
||||||
throw new Error(body?.error ?? `Request failed: ${res.status}`);
|
throw new ApiError(
|
||||||
|
(errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
errorBody,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res.json();
|
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 { api } from "./client";
|
||||||
|
export { authApi } from "./auth";
|
||||||
|
export { healthApi } from "./health";
|
||||||
|
export { accessApi } from "./access";
|
||||||
export { companiesApi } from "./companies";
|
export { companiesApi } from "./companies";
|
||||||
export { agentsApi } from "./agents";
|
export { agentsApi } from "./agents";
|
||||||
export { projectsApi } from "./projects";
|
export { projectsApi } from "./projects";
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { Approval, Issue, IssueAttachment, IssueComment } from "@paperclip/
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const issuesApi = {
|
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}`),
|
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||||
create: (companyId: string, data: Record<string, unknown>) =>
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
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 { Identity } from "./Identity";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -85,8 +85,6 @@ interface ActivityRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) {
|
export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const verb = formatVerb(event.action, event.details);
|
const verb = formatVerb(event.action, event.details);
|
||||||
|
|
||||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
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 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 (
|
return (
|
||||||
<div
|
<div className={classes}>
|
||||||
className={cn(
|
{inner}
|
||||||
"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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type AgentConfigFormProps = {
|
|||||||
onSaveActionChange?: (save: (() => void) | null) => void;
|
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||||
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||||
hideInlineSave?: boolean;
|
hideInlineSave?: boolean;
|
||||||
|
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||||
|
sectionLayout?: "inline" | "cards";
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
mode: "create";
|
mode: "create";
|
||||||
@@ -138,6 +140,7 @@ function extractPickedDirectoryPath(handle: unknown): string | null {
|
|||||||
export function AgentConfigForm(props: AgentConfigFormProps) {
|
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
const { mode, adapterModels: externalModels } = props;
|
const { mode, adapterModels: externalModels } = props;
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
|
const cards = props.sectionLayout === "cards";
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -324,7 +327,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className={cn("relative", cards && "space-y-6")}>
|
||||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||||
{isDirty && !props.hideInlineSave && (
|
{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">
|
<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) ---- */}
|
{/* ---- Identity (edit only) ---- */}
|
||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<div className="border-b border-border">
|
<div className={cn(!cards && "border-b border-border")}>
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
{cards
|
||||||
<div className="px-4 pb-3 space-y-3">
|
? <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}>
|
<Field label="Name" hint={help.name}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={eff("identity", "name", props.agent.name)}
|
value={eff("identity", "name", props.agent.name)}
|
||||||
@@ -403,11 +409,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- Adapter ---- */}
|
{/* ---- Adapter ---- */}
|
||||||
<div className={cn(isCreate ? "border-t border-border" : "border-b border-border")}>
|
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||||
<div className="px-4 py-2 flex items-center justify-between gap-2">
|
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
{cards
|
||||||
Adapter
|
? <h3 className="text-sm font-medium">Adapter</h3>
|
||||||
</span>
|
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
|
||||||
|
}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -419,7 +426,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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}>
|
<Field label="Adapter type" hint={help.adapterType}>
|
||||||
<AdapterTypeDropdown
|
<AdapterTypeDropdown
|
||||||
value={adapterType}
|
value={adapterType}
|
||||||
@@ -531,11 +538,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
|
|
||||||
{/* ---- Permissions & Configuration ---- */}
|
{/* ---- Permissions & Configuration ---- */}
|
||||||
{isLocal && (
|
{isLocal && (
|
||||||
<div className="border-b border-border">
|
<div className={cn(!cards && "border-b border-border")}>
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
{cards
|
||||||
Permissions & Configuration
|
? <h3 className="text-sm font-medium mb-3">Permissions & Configuration</h3>
|
||||||
</div>
|
: <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 border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||||
<Field label="Command" hint={help.localCommand}>
|
<Field label="Command" hint={help.localCommand}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={
|
value={
|
||||||
@@ -689,12 +697,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
|
|
||||||
{/* ---- Run Policy ---- */}
|
{/* ---- Run Policy ---- */}
|
||||||
{isCreate ? (
|
{isCreate ? (
|
||||||
<div className="border-b border-border">
|
<div className={cn(!cards && "border-b border-border")}>
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
{cards
|
||||||
<Heart className="h-3 w-3" />
|
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||||
Run Policy
|
: <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>
|
}
|
||||||
<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")}>
|
||||||
<ToggleWithNumber
|
<ToggleWithNumber
|
||||||
label="Heartbeat on interval"
|
label="Heartbeat on interval"
|
||||||
hint={help.heartbeatInterval}
|
hint={help.heartbeatInterval}
|
||||||
@@ -710,30 +718,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-b border-border">
|
<div className={cn(!cards && "border-b border-border")}>
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
{cards
|
||||||
<Heart className="h-3 w-3" />
|
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||||
Run Policy
|
: <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>
|
}
|
||||||
<div className="px-4 pb-3 space-y-3">
|
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
|
||||||
<ToggleWithNumber
|
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||||
label="Heartbeat on interval"
|
<ToggleWithNumber
|
||||||
hint={help.heartbeatInterval}
|
label="Heartbeat on interval"
|
||||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
hint={help.heartbeatInterval}
|
||||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||||
numberLabel="sec"
|
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||||
numberPrefix="Run heartbeat every"
|
numberLabel="sec"
|
||||||
numberHint={help.intervalSec}
|
numberPrefix="Run heartbeat every"
|
||||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
numberHint={help.intervalSec}
|
||||||
/>
|
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||||
</div>
|
/>
|
||||||
<CollapsibleSection
|
</div>
|
||||||
title="Advanced Run Policy"
|
<CollapsibleSection
|
||||||
open={runPolicyAdvancedOpen}
|
title="Advanced Run Policy"
|
||||||
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
|
bordered={cards}
|
||||||
>
|
open={runPolicyAdvancedOpen}
|
||||||
|
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="Wake on demand"
|
label="Wake on demand"
|
||||||
@@ -771,6 +781,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||||
@@ -19,13 +20,15 @@ export function ApprovalCard({
|
|||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
detailLink,
|
||||||
isPending,
|
isPending,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
requesterAgent: Agent | null;
|
requesterAgent: Agent | null;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
onOpen: () => void;
|
onOpen?: () => void;
|
||||||
|
detailLink?: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||||
@@ -85,9 +88,15 @@ export function ApprovalCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
{detailLink ? (
|
||||||
View details
|
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||||
</Button>
|
<Link to={detailLink}>View details</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||||
|
View details
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function BreadcrumbBar() {
|
|||||||
// Single breadcrumb = page title (uppercase)
|
// Single breadcrumb = page title (uppercase)
|
||||||
if (breadcrumbs.length === 1) {
|
if (breadcrumbs.length === 1) {
|
||||||
return (
|
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}
|
{menuButton}
|
||||||
<h1 className="text-sm font-semibold uppercase tracking-wider">
|
<h1 className="text-sm font-semibold uppercase tracking-wider">
|
||||||
{breadcrumbs[0].label}
|
{breadcrumbs[0].label}
|
||||||
@@ -44,7 +44,7 @@ export function BreadcrumbBar() {
|
|||||||
|
|
||||||
// Multiple breadcrumbs = breadcrumb trail
|
// Multiple breadcrumbs = breadcrumb trail
|
||||||
return (
|
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}
|
{menuButton}
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { formatDateTime } from "../lib/utils";
|
import { formatDateTime } from "../lib/utils";
|
||||||
|
|
||||||
@@ -17,11 +17,12 @@ interface CommentThreadProps {
|
|||||||
onAdd: (body: string, reopen?: boolean) => Promise<void>;
|
onAdd: (body: string, reopen?: boolean) => Promise<void>;
|
||||||
issueStatus?: string;
|
issueStatus?: string;
|
||||||
agentMap?: Map<string, Agent>;
|
agentMap?: Map<string, Agent>;
|
||||||
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
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 [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
const [reopen, setReopen] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -35,13 +36,15 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
[comments],
|
[comments],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build mention options from agent map
|
// Build mention options from agent map (exclude terminated agents)
|
||||||
const mentions = useMemo<MentionOption[]>(() => {
|
const mentions = useMemo<MentionOption[]>(() => {
|
||||||
if (!agentMap) return [];
|
if (!agentMap) return [];
|
||||||
return Array.from(agentMap.values()).map((a) => ({
|
return Array.from(agentMap.values())
|
||||||
id: a.id,
|
.filter((a) => a.status !== "terminated")
|
||||||
name: a.name,
|
.map((a) => ({
|
||||||
}));
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
}));
|
||||||
}, [agentMap]);
|
}, [agentMap]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -84,9 +87,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
{formatDateTime(comment.createdAt)}
|
{formatDateTime(comment.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||||
<Markdown>{comment.body}</Markdown>
|
|
||||||
</div>
|
|
||||||
{comment.runId && comment.runAgentId && (
|
{comment.runId && comment.runAgentId && (
|
||||||
<div className="mt-2 pt-2 border-t border-border/60">
|
<div className="mt-2 pt-2 border-t border-border/60">
|
||||||
<Link
|
<Link
|
||||||
@@ -109,6 +110,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
placeholder="Leave a comment..."
|
placeholder="Leave a comment..."
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
contentClassName="min-h-[60px] text-sm"
|
contentClassName="min-h-[60px] text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-3">
|
<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 { type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
interface EntityRowProps {
|
interface EntityRowProps {
|
||||||
@@ -8,6 +9,7 @@ interface EntityRowProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
to?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -19,19 +21,20 @@ export function EntityRow({
|
|||||||
subtitle,
|
subtitle,
|
||||||
trailing,
|
trailing,
|
||||||
selected,
|
selected,
|
||||||
|
to,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: EntityRowProps) {
|
}: EntityRowProps) {
|
||||||
return (
|
const isClickable = !!(to || onClick);
|
||||||
<div
|
const classes = cn(
|
||||||
className={cn(
|
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||||
"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",
|
||||||
onClick && "cursor-pointer hover:bg-accent/50",
|
selected && "bg-accent/30",
|
||||||
selected && "bg-accent/30",
|
className
|
||||||
className
|
);
|
||||||
)}
|
|
||||||
onClick={onClick}
|
const content = (
|
||||||
>
|
<>
|
||||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -47,6 +50,20 @@ export function EntityRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Goal } from "@paperclip/shared";
|
import type { Goal } from "@paperclip/shared";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -6,6 +7,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
interface GoalTreeProps {
|
interface GoalTreeProps {
|
||||||
goals: Goal[];
|
goals: Goal[];
|
||||||
|
goalLink?: (goal: Goal) => string;
|
||||||
onSelect?: (goal: Goal) => void;
|
onSelect?: (goal: Goal) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,41 +16,62 @@ interface GoalNodeProps {
|
|||||||
children: Goal[];
|
children: Goal[];
|
||||||
allGoals: Goal[];
|
allGoals: Goal[];
|
||||||
depth: number;
|
depth: number;
|
||||||
|
goalLink?: (goal: Goal) => string;
|
||||||
onSelect?: (goal: Goal) => void;
|
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 [expanded, setExpanded] = useState(true);
|
||||||
const hasChildren = children.length > 0;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
{link ? (
|
||||||
className={cn(
|
<Link
|
||||||
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
|
to={link}
|
||||||
)}
|
className={cn(classes, "no-underline text-inherit")}
|
||||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||||
onClick={() => onSelect?.(goal)}
|
>
|
||||||
>
|
{inner}
|
||||||
{hasChildren ? (
|
</Link>
|
||||||
<button
|
) : (
|
||||||
className="p-0.5"
|
<div
|
||||||
onClick={(e) => {
|
className={classes}
|
||||||
e.stopPropagation();
|
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||||
setExpanded(!expanded);
|
onClick={() => onSelect?.(goal)}
|
||||||
}}
|
>
|
||||||
>
|
{inner}
|
||||||
<ChevronRight
|
</div>
|
||||||
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>
|
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
<div>
|
<div>
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
@@ -58,6 +81,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
|
|||||||
children={allGoals.filter((g) => g.parentId === child.id)}
|
children={allGoals.filter((g) => g.parentId === child.id)}
|
||||||
allGoals={allGoals}
|
allGoals={allGoals}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
goalLink={goalLink}
|
||||||
onSelect={onSelect}
|
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);
|
const roots = goals.filter((g) => !g.parentId);
|
||||||
|
|
||||||
if (goals.length === 0) {
|
if (goals.length === 0) {
|
||||||
@@ -83,6 +107,7 @@ export function GoalTree({ goals, onSelect }: GoalTreeProps) {
|
|||||||
children={goals.filter((g) => g.parentId === goal.id)}
|
children={goals.filter((g) => g.parentId === goal.id)}
|
||||||
allGoals={goals}
|
allGoals={goals}
|
||||||
depth={0}
|
depth={0}
|
||||||
|
goalLink={goalLink}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
|
||||||
interface InlineEditorProps {
|
interface InlineEditorProps {
|
||||||
@@ -138,9 +138,7 @@ export function InlineEditor({
|
|||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
>
|
>
|
||||||
{value && multiline ? (
|
{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">
|
<MarkdownBody>{value}</MarkdownBody>
|
||||||
<Markdown>{value}</Markdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
value || placeholder
|
value || placeholder
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { timeAgo } from "../lib/timeAgo";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { User, Hexagon, ArrowUpRight } from "lucide-react";
|
import { User, Hexagon, ArrowUpRight } from "lucide-react";
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
|
||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
@@ -130,6 +131,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
||||||
>
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
{a.name}
|
{a.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -151,7 +153,13 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
<PopoverTrigger asChild>
|
<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">
|
<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 ? (
|
{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" />
|
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -160,7 +168,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-52 p-1" align="end">
|
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end">
|
||||||
<input
|
<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"
|
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..."
|
placeholder="Search projects..."
|
||||||
@@ -170,7 +178,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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"
|
!issue.projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||||
@@ -187,11 +195,15 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={cn(
|
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"
|
p.id === issue.projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState, useCallback } from "react";
|
import { useMemo, useState, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { formatDate } from "../lib/utils";
|
import { formatDate } from "../lib/utils";
|
||||||
@@ -139,7 +139,6 @@ export function IssuesList({
|
|||||||
onUpdateIssue,
|
onUpdateIssue,
|
||||||
}: IssuesListProps) {
|
}: IssuesListProps) {
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [viewState, setViewState] = useState<IssueViewState>(() => getViewState(viewStateKey));
|
const [viewState, setViewState] = useState<IssueViewState>(() => getViewState(viewStateKey));
|
||||||
|
|
||||||
@@ -202,7 +201,7 @@ export function IssuesList({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
New Issue
|
New Issue
|
||||||
</Button>
|
</Button>
|
||||||
@@ -434,16 +433,14 @@ export function IssuesList({
|
|||||||
)}
|
)}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{group.items.map((issue) => (
|
{group.items.map((issue) => (
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
<Link
|
||||||
<div
|
|
||||||
key={issue.id}
|
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"
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
onClick={() => navigate(`/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 */}
|
{/* Spacer matching caret width so status icon aligns with group title */}
|
||||||
<div className="w-3.5 shrink-0" />
|
<div className="w-3.5 shrink-0" />
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||||
@@ -473,7 +470,7 @@ export function IssuesList({
|
|||||||
{formatDate(issue.createdAt)}
|
{formatDate(issue.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { CompanyRail } from "./CompanyRail";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||||
import { PropertiesPanel } from "./PropertiesPanel";
|
import { PropertiesPanel } from "./PropertiesPanel";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
@@ -15,31 +19,50 @@ import { usePanel } from "../context/PanelContext";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||||
|
import { healthApi } from "../api/health";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialog();
|
||||||
const { panelContent, closePanel } = usePanel();
|
const { panelContent, closePanel } = usePanel();
|
||||||
const { companies, loading: companiesLoading } = useCompany();
|
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
|
||||||
const onboardingTriggered = useRef(false);
|
const onboardingTriggered = useRef(false);
|
||||||
|
const { data: health } = useQuery({
|
||||||
|
queryKey: queryKeys.health,
|
||||||
|
queryFn: () => healthApi.get(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companiesLoading || onboardingTriggered.current) return;
|
if (companiesLoading || onboardingTriggered.current) return;
|
||||||
|
if (health?.deploymentMode === "authenticated") return;
|
||||||
if (companies.length === 0) {
|
if (companies.length === 0) {
|
||||||
onboardingTriggered.current = true;
|
onboardingTriggered.current = true;
|
||||||
openOnboarding();
|
openOnboarding();
|
||||||
}
|
}
|
||||||
}, [companies, companiesLoading, openOnboarding]);
|
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
||||||
|
|
||||||
const togglePanel = useCallback(() => {
|
const togglePanel = useCallback(() => {
|
||||||
if (panelContent) closePanel();
|
if (panelContent) closePanel();
|
||||||
}, [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({
|
useKeyboardShortcuts({
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
|
onSwitchCompany: switchCompany,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,24 +75,40 @@ export function Layout() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="flex flex-col shrink-0 h-full">
|
||||||
className={cn(
|
<div className="flex flex-1 min-h-0">
|
||||||
"shrink-0 h-full overflow-hidden transition-all duration-200 ease-in-out",
|
<CompanyRail />
|
||||||
sidebarOpen ? "w-60" : "w-0"
|
<div
|
||||||
)}
|
className={cn(
|
||||||
>
|
"overflow-hidden transition-all duration-200 ease-in-out",
|
||||||
<Sidebar />
|
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>
|
</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 { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
@@ -7,25 +8,22 @@ interface MetricCardProps {
|
|||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
|
to?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
|
export function MetricCard({ icon: Icon, value, label, description, to, onClick }: MetricCardProps) {
|
||||||
return (
|
const isClickable = !!(to || onClick);
|
||||||
|
|
||||||
|
const inner = (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-3 sm:p-4">
|
<CardContent className="p-3 sm:p-4">
|
||||||
<div className="flex gap-2 sm:gap-3">
|
<div className="flex gap-2 sm:gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p
|
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
|
||||||
className={`text-lg sm:text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className={`text-xs sm:text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
|
||||||
className={`text-xs sm:text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{description && (
|
{description && (
|
||||||
@@ -39,4 +37,22 @@ export function MetricCard({ icon: Icon, value, label, description, onClick }: M
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
import { defaultCreateValues } from "./agent-config-defaults";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||||
@@ -163,9 +164,9 @@ export function NewAgentDialog() {
|
|||||||
|
|
||||||
<div className="overflow-y-auto max-h-[70vh]">
|
<div className="overflow-y-auto max-h-[70vh]">
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||||
<input
|
<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"
|
placeholder="Agent name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
@@ -225,13 +226,17 @@ export function NewAgentDialog() {
|
|||||||
)}
|
)}
|
||||||
disabled={isFirstAgent}
|
disabled={isFirstAgent}
|
||||||
>
|
>
|
||||||
<User className="h-3 w-3 text-muted-foreground" />
|
{currentReportsTo ? (
|
||||||
{currentReportsTo
|
<>
|
||||||
? `Reports to ${currentReportsTo.name}`
|
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
|
||||||
: isFirstAgent
|
{`Reports to ${currentReportsTo.name}`}
|
||||||
? "Reports to: N/A (CEO)"
|
</>
|
||||||
: "Reports to..."
|
) : (
|
||||||
}
|
<>
|
||||||
|
<User className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-48 p-1" align="start">
|
<PopoverContent className="w-48 p-1" align="start">
|
||||||
@@ -253,6 +258,7 @@ export function NewAgentDialog() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
||||||
>
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
{a.name}
|
{a.name}
|
||||||
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -151,9 +151,9 @@ export function NewGoalDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||||
<input
|
<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"
|
placeholder="Goal title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import type { Project, Agent } from "@paperclip/shared";
|
import type { Project, Agent } from "@paperclip/shared";
|
||||||
|
|
||||||
const DRAFT_KEY = "paperclip:issue-draft";
|
const DRAFT_KEY = "paperclip:issue-draft";
|
||||||
@@ -373,8 +374,17 @@ export function NewIssueDialog() {
|
|||||||
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
||||||
<PopoverTrigger asChild>
|
<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">
|
<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 ? currentAssignee.name : "Assignee"}
|
<>
|
||||||
|
<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>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-52 p-1" align="start">
|
<PopoverContent className="w-52 p-1" align="start">
|
||||||
@@ -410,6 +420,7 @@ export function NewIssueDialog() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
||||||
>
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
{a.name}
|
{a.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -420,14 +431,26 @@ export function NewIssueDialog() {
|
|||||||
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
||||||
<PopoverTrigger asChild>
|
<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">
|
<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 ? currentProject.name : "Project"}
|
<>
|
||||||
|
<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>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-44 p-1" align="start">
|
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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"
|
!projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
||||||
@@ -438,11 +461,15 @@ export function NewIssueDialog() {
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={cn(
|
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"
|
p.id === projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { PROJECT_COLORS } from "@paperclip/shared";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
@@ -89,6 +90,7 @@ export function NewProjectDialog() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
|
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
||||||
...(goalIds.length > 0 ? { goalIds } : {}),
|
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||||
...(targetDate ? { targetDate } : {}),
|
...(targetDate ? { targetDate } : {}),
|
||||||
});
|
});
|
||||||
@@ -151,9 +153,9 @@ export function NewProjectDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||||
<input
|
<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"
|
placeholder="Project name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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 { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
@@ -15,6 +16,27 @@ import {
|
|||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import type { Agent } from "@paperclip/shared";
|
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() {
|
export function SidebarAgents() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
@@ -27,9 +49,27 @@ export function SidebarAgents() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleAgents = (agents ?? []).filter(
|
const { data: liveRuns } = useQuery({
|
||||||
(a: Agent) => a.status !== "terminated"
|
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 agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
|
||||||
const activeAgentId = agentMatch?.[1] ?? null;
|
const activeAgentId = agentMatch?.[1] ?? null;
|
||||||
@@ -54,24 +94,38 @@ export function SidebarAgents() {
|
|||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
{visibleAgents.map((agent: Agent) => (
|
{visibleAgents.map((agent: Agent) => {
|
||||||
<NavLink
|
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||||
key={agent.id}
|
return (
|
||||||
to={`/agents/${agent.id}`}
|
<NavLink
|
||||||
onClick={() => {
|
key={agent.id}
|
||||||
if (isMobile) setSidebarOpen(false);
|
to={`/agents/${agent.id}`}
|
||||||
}}
|
onClick={() => {
|
||||||
className={cn(
|
if (isMobile) setSidebarOpen(false);
|
||||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
}}
|
||||||
activeAgentId === agent.id
|
className={cn(
|
||||||
? "bg-accent text-foreground"
|
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
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>
|
<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>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface SidebarNavItemProps {
|
|||||||
badge?: number;
|
badge?: number;
|
||||||
badgeTone?: "default" | "danger";
|
badgeTone?: "default" | "danger";
|
||||||
alert?: boolean;
|
alert?: boolean;
|
||||||
|
liveCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarNavItem({
|
export function SidebarNavItem({
|
||||||
@@ -21,6 +22,7 @@ export function SidebarNavItem({
|
|||||||
badge,
|
badge,
|
||||||
badgeTone = "default",
|
badgeTone = "default",
|
||||||
alert = false,
|
alert = false,
|
||||||
|
liveCount,
|
||||||
}: SidebarNavItemProps) {
|
}: SidebarNavItemProps) {
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
@@ -45,6 +47,15 @@ export function SidebarNavItem({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">{label}</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 && (
|
{badge != null && badge > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function SidebarProjects() {
|
|||||||
{visibleProjects.map((project: Project) => (
|
{visibleProjects.map((project: Project) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={project.id}
|
key={project.id}
|
||||||
to={`/projects/${project.id}`}
|
to={`/projects/${project.id}/issues`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Company } from "@paperclip/shared";
|
import type { Company } from "@paperclip/shared";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
interface CompanyContextValue {
|
interface CompanyContextValue {
|
||||||
@@ -39,7 +40,17 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const { data: companies = [], isLoading, error } = useQuery({
|
const { data: companies = [], isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.companies.all,
|
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
|
// Auto-select first company when list loads
|
||||||
|
|||||||
@@ -75,16 +75,49 @@ interface IssueToastContext {
|
|||||||
href: string;
|
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(
|
function resolveIssueToastContext(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
issueId: string,
|
issueId: string,
|
||||||
details: Record<string, unknown> | null,
|
details: Record<string, unknown> | null,
|
||||||
): IssueToastContext {
|
): 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
|
const listIssue = queryClient
|
||||||
.getQueryData<Issue[]>(queryKeys.issues.list(companyId))
|
.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 cachedIssue = detailIssue ?? listIssue ?? null;
|
||||||
const ref =
|
const ref =
|
||||||
readString(details?.identifier) ??
|
readString(details?.identifier) ??
|
||||||
@@ -290,12 +323,16 @@ function invalidateActivityQueries(
|
|||||||
if (entityType === "issue") {
|
if (entityType === "issue") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
if (entityId) {
|
if (entityId) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) });
|
const details = readRecord(payload.details);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) });
|
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) });
|
for (const ref of issueRefs) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ interface ShortcutHandlers {
|
|||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
|
onSwitchCompany?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
@@ -15,6 +16,13 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||||||
return;
|
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
|
// C → New Issue
|
||||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
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,
|
detail: (id: string) => ["issues", "detail", id] as const,
|
||||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||||
attachments: (issueId: string) => ["issues", "attachments", 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,
|
comments: (approvalId: string) => ["approvals", "comments", approvalId] as const,
|
||||||
issues: (approvalId: string) => ["approvals", "issues", 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: {
|
secrets: {
|
||||||
list: (companyId: string) => ["secrets", companyId] as const,
|
list: (companyId: string) => ["secrets", companyId] as const,
|
||||||
providers: (companyId: string) => ["secret-providers", 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 { 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 { useQuery } from "@tanstack/react-query";
|
||||||
import { agentsApi, type OrgNode } from "../api/agents";
|
import { agentsApi, type OrgNode } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
@@ -191,7 +191,7 @@ export function Agents() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" onClick={openNewAgent}>
|
<Button size="sm" variant="outline" onClick={openNewAgent}>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
New Agent
|
New Agent
|
||||||
</Button>
|
</Button>
|
||||||
@@ -223,7 +223,7 @@ export function Agents() {
|
|||||||
key={agent.id}
|
key={agent.id}
|
||||||
title={agent.name}
|
title={agent.name}
|
||||||
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
to={`/agents/${agent.id}`}
|
||||||
leading={
|
leading={
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
<span
|
<span
|
||||||
@@ -251,7 +251,6 @@ export function Agents() {
|
|||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||||
navigate={navigate}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status={agent.status} />
|
<StatusBadge status={agent.status} />
|
||||||
@@ -263,7 +262,6 @@ export function Agents() {
|
|||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||||
navigate={navigate}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
<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 && (
|
{effectiveView === "org" && filteredOrg.length > 0 && (
|
||||||
<div className="border border-border py-1">
|
<div className="border border-border py-1">
|
||||||
{filteredOrg.map((node) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -317,13 +315,11 @@ export function Agents() {
|
|||||||
function OrgTreeNode({
|
function OrgTreeNode({
|
||||||
node,
|
node,
|
||||||
depth,
|
depth,
|
||||||
navigate,
|
|
||||||
agentMap,
|
agentMap,
|
||||||
liveRunByAgent,
|
liveRunByAgent,
|
||||||
}: {
|
}: {
|
||||||
node: OrgNode;
|
node: OrgNode;
|
||||||
depth: number;
|
depth: number;
|
||||||
navigate: (path: string) => void;
|
|
||||||
agentMap: Map<string, Agent>;
|
agentMap: Map<string, Agent>;
|
||||||
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
||||||
}) {
|
}) {
|
||||||
@@ -344,9 +340,9 @@ function OrgTreeNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingLeft: depth * 24 }}>
|
<div style={{ paddingLeft: depth * 24 }}>
|
||||||
<button
|
<Link
|
||||||
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left"
|
to={`/agents/${node.id}`}
|
||||||
onClick={() => navigate(`/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="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
||||||
@@ -365,7 +361,6 @@ function OrgTreeNode({
|
|||||||
agentId={node.id}
|
agentId={node.id}
|
||||||
runId={liveRunByAgent.get(node.id)!.runId}
|
runId={liveRunByAgent.get(node.id)!.runId}
|
||||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||||
navigate={navigate}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
@@ -377,7 +372,6 @@ function OrgTreeNode({
|
|||||||
agentId={node.id}
|
agentId={node.id}
|
||||||
runId={liveRunByAgent.get(node.id)!.runId}
|
runId={liveRunByAgent.get(node.id)!.runId}
|
||||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||||
navigate={navigate}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && (
|
{agent && (
|
||||||
@@ -395,11 +389,11 @@ function OrgTreeNode({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
{node.reports && node.reports.length > 0 && (
|
{node.reports && node.reports.length > 0 && (
|
||||||
<div className="border-l border-border/50 ml-4">
|
<div className="border-l border-border/50 ml-4">
|
||||||
{node.reports.map((child) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -411,20 +405,16 @@ function LiveRunIndicator({
|
|||||||
agentId,
|
agentId,
|
||||||
runId,
|
runId,
|
||||||
liveCount,
|
liveCount,
|
||||||
navigate,
|
|
||||||
}: {
|
}: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
liveCount: number;
|
liveCount: number;
|
||||||
navigate: (path: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
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"
|
to={`/agents/${agentId}/runs/${runId}`}
|
||||||
onClick={(e) => {
|
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"
|
||||||
e.stopPropagation();
|
onClick={(e) => e.stopPropagation()}
|
||||||
navigate(`/agents/${agentId}/runs/${runId}`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<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="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">
|
<span className="text-[11px] font-medium text-blue-400">
|
||||||
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
||||||
import type { ApprovalComment } from "@paperclip/shared";
|
import type { ApprovalComment } from "@paperclip/shared";
|
||||||
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
|
|
||||||
export function ApprovalDetail() {
|
export function ApprovalDetail() {
|
||||||
const { approvalId } = useParams<{ approvalId: string }>();
|
const { approvalId } = useParams<{ approvalId: string }>();
|
||||||
@@ -329,7 +330,7 @@ export function ApprovalDetail() {
|
|||||||
{new Date(comment.createdAt).toLocaleString()}
|
{new Date(comment.createdAt).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
@@ -11,6 +12,10 @@ export function CompanySettings() {
|
|||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const settingsMutation = useMutation({
|
||||||
mutationFn: (requireApproval: boolean) =>
|
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(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||||
@@ -75,6 +105,63 @@ export function CompanySettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
@@ -31,7 +31,6 @@ export function Dashboard() {
|
|||||||
const { selectedCompanyId, selectedCompany, companies } = useCompany();
|
const { selectedCompanyId, selectedCompany, companies } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||||
const hydratedActivityRef = useRef(false);
|
const hydratedActivityRef = useRef(false);
|
||||||
@@ -180,14 +179,12 @@ export function Dashboard() {
|
|||||||
icon={Bot}
|
icon={Bot}
|
||||||
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
||||||
label="Agents Enabled"
|
label="Agents Enabled"
|
||||||
onClick={() => navigate("/agents")}
|
to="/agents"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.running} running</span>
|
{data.agents.running} running{", "}
|
||||||
{", "}
|
{data.agents.paused} paused{", "}
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
|
{data.agents.error} errors
|
||||||
{", "}
|
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -195,12 +192,11 @@ export function Dashboard() {
|
|||||||
icon={CircleDot}
|
icon={CircleDot}
|
||||||
value={data.tasks.inProgress}
|
value={data.tasks.inProgress}
|
||||||
label="Tasks In Progress"
|
label="Tasks In Progress"
|
||||||
onClick={() => navigate("/issues")}
|
to="/issues"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.open} open</span>
|
{data.tasks.open} open{", "}
|
||||||
{", "}
|
{data.tasks.blocked} blocked
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.blocked} blocked</span>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -208,9 +204,9 @@ export function Dashboard() {
|
|||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
value={formatCents(data.costs.monthSpendCents)}
|
value={formatCents(data.costs.monthSpendCents)}
|
||||||
label="Month Spend"
|
label="Month Spend"
|
||||||
onClick={() => navigate("/costs")}
|
to="/costs"
|
||||||
description={
|
description={
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/costs")}>
|
<span>
|
||||||
{data.costs.monthBudgetCents > 0
|
{data.costs.monthBudgetCents > 0
|
||||||
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
|
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
|
||||||
: "Unlimited budget"}
|
: "Unlimited budget"}
|
||||||
@@ -221,9 +217,9 @@ export function Dashboard() {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
value={data.pendingApprovals}
|
value={data.pendingApprovals}
|
||||||
label="Pending Approvals"
|
label="Pending Approvals"
|
||||||
onClick={() => navigate("/approvals")}
|
to="/approvals"
|
||||||
description={
|
description={
|
||||||
<span className="cursor-pointer" onClick={() => navigate("/issues")}>
|
<span>
|
||||||
{data.staleTasks} stale tasks
|
{data.staleTasks} stale tasks
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -263,10 +259,10 @@ export function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border border-border divide-y divide-border">
|
<div className="border border-border divide-y divide-border">
|
||||||
{recentIssues.slice(0, 10).map((issue) => (
|
{recentIssues.slice(0, 10).map((issue) => (
|
||||||
<div
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
onClick={() => navigate(`/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 gap-3">
|
||||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||||
@@ -288,7 +284,7 @@ export function Dashboard() {
|
|||||||
{timeAgo(issue.updatedAt)}
|
{timeAgo(issue.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
@@ -25,42 +25,54 @@ export function GoalDetail() {
|
|||||||
const { openNewGoal } = useDialog();
|
const { openNewGoal } = useDialog();
|
||||||
const { openPanel, closePanel } = usePanel();
|
const { openPanel, closePanel } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: goal, isLoading, error } = useQuery({
|
const {
|
||||||
|
data: goal,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
queryKey: queryKeys.goals.detail(goalId!),
|
queryKey: queryKeys.goals.detail(goalId!),
|
||||||
queryFn: () => goalsApi.get(goalId!),
|
queryFn: () => goalsApi.get(goalId!),
|
||||||
enabled: !!goalId,
|
enabled: !!goalId
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allGoals } = useQuery({
|
const { data: allGoals } = useQuery({
|
||||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allProjects } = useQuery({
|
const { data: allProjects } = useQuery({
|
||||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateGoal = useMutation({
|
const updateGoal = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => goalsApi.update(goalId!, data),
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
goalsApi.update(goalId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(goalId!) });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.goals.detail(goalId!)
|
||||||
|
});
|
||||||
if (selectedCompanyId) {
|
if (selectedCompanyId) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(selectedCompanyId) });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.goals.list(selectedCompanyId)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadImage = useMutation({
|
const uploadImage = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
if (!selectedCompanyId) throw new Error("No company selected");
|
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);
|
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
|
||||||
@@ -74,20 +86,24 @@ export function GoalDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: "Goals", href: "/goals" },
|
{ label: "Goals", href: "/goals" },
|
||||||
{ label: goal?.title ?? goalId ?? "Goal" },
|
{ label: goal?.title ?? goalId ?? "Goal" }
|
||||||
]);
|
]);
|
||||||
}, [setBreadcrumbs, goal, goalId]);
|
}, [setBreadcrumbs, goal, goalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (goal) {
|
if (goal) {
|
||||||
openPanel(
|
openPanel(
|
||||||
<GoalProperties goal={goal} onUpdate={(data) => updateGoal.mutate(data)} />
|
<GoalProperties
|
||||||
|
goal={goal}
|
||||||
|
onUpdate={(data) => updateGoal.mutate(data)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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 (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
if (!goal) return null;
|
if (!goal) return null;
|
||||||
|
|
||||||
@@ -95,7 +111,9 @@ export function GoalDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<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} />
|
<StatusBadge status={goal.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,13 +140,21 @@ export function GoalDetail() {
|
|||||||
|
|
||||||
<Tabs defaultValue="children">
|
<Tabs defaultValue="children">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="children">Sub-Goals ({childGoals.length})</TabsTrigger>
|
<TabsTrigger value="children">
|
||||||
<TabsTrigger value="projects">Projects ({linkedProjects.length})</TabsTrigger>
|
Sub-Goals ({childGoals.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="projects">
|
||||||
|
Projects ({linkedProjects.length})
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="children" className="mt-4 space-y-3">
|
<TabsContent value="children" className="mt-4 space-y-3">
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-start">
|
||||||
<Button size="sm" variant="outline" onClick={() => openNewGoal({ parentId: goalId })}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openNewGoal({ parentId: goalId })}
|
||||||
|
>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Sub Goal
|
Sub Goal
|
||||||
</Button>
|
</Button>
|
||||||
@@ -136,10 +162,7 @@ export function GoalDetail() {
|
|||||||
{childGoals.length === 0 ? (
|
{childGoals.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No sub-goals.</p>
|
<p className="text-sm text-muted-foreground">No sub-goals.</p>
|
||||||
) : (
|
) : (
|
||||||
<GoalTree
|
<GoalTree goals={childGoals} goalLink={(g) => `/goals/${g.id}`} />
|
||||||
goals={childGoals}
|
|
||||||
onSelect={(g) => navigate(`/goals/${g.id}`)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -153,7 +176,7 @@ export function GoalDetail() {
|
|||||||
key={project.id}
|
key={project.id}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
subtitle={project.description ?? undefined}
|
subtitle={project.description ?? undefined}
|
||||||
onClick={() => navigate(`/projects/${project.id}`)}
|
to={`/projects/${project.id}`}
|
||||||
trailing={<StatusBadge status={project.status} />}
|
trailing={<StatusBadge status={project.status} />}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -15,7 +14,6 @@ export function Goals() {
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewGoal } = useDialog();
|
const { openNewGoal } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Goals" }]);
|
setBreadcrumbs([{ label: "Goals" }]);
|
||||||
@@ -47,13 +45,13 @@ export function Goals() {
|
|||||||
|
|
||||||
{goals && goals.length > 0 && (
|
{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()}>
|
<Button size="sm" variant="outline" onClick={() => openNewGoal()}>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
New Goal
|
New Goal
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
|
<GoalTree goals={goals} goalLink={(goal) => `/goals/${goal.id}`} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -17,19 +19,39 @@ import { StatusBadge } from "../components/StatusBadge";
|
|||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Identity } from "../components/Identity";
|
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 STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
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> = {
|
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||||
timer: "Scheduled",
|
timer: "Scheduled",
|
||||||
@@ -44,12 +66,9 @@ function getStaleIssues(issues: Issue[]): Issue[] {
|
|||||||
.filter(
|
.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
["in_progress", "todo"].includes(i.status) &&
|
["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(
|
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
||||||
(a, b) =>
|
|
||||||
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||||
@@ -64,9 +83,7 @@ function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(latestByAgent.values()).filter((run) =>
|
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
|
||||||
FAILED_RUN_STATUSES.has(run.status),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
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 {
|
function runFailureMessage(run: HeartbeatRun): string {
|
||||||
return (
|
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||||
firstNonEmptyLine(run.error) ??
|
|
||||||
firstNonEmptyLine(run.stderrExcerpt) ??
|
|
||||||
"Run exited with an error."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
@@ -100,8 +113,14 @@ export function Inbox() {
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
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({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -113,25 +132,48 @@ export function Inbox() {
|
|||||||
setBreadcrumbs([{ label: "Inbox" }]);
|
setBreadcrumbs([{ label: "Inbox" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({
|
const {
|
||||||
|
data: approvals,
|
||||||
|
isLoading: isApprovalsLoading,
|
||||||
|
error: approvalsError,
|
||||||
|
} = useQuery({
|
||||||
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
||||||
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
||||||
enabled: !!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!),
|
queryKey: queryKeys.dashboard(selectedCompanyId!),
|
||||||
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: issues } = useQuery({
|
const { data: issues, isLoading: isIssuesLoading } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: heartbeatRuns } = useQuery({
|
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
||||||
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
||||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
@@ -156,6 +198,28 @@ export function Inbox() {
|
|||||||
[heartbeatRuns],
|
[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) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return agentById.get(id) ?? null;
|
return agentById.get(id) ?? null;
|
||||||
@@ -164,6 +228,7 @@ export function Inbox() {
|
|||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||||
onSuccess: (_approval, id) => {
|
onSuccess: (_approval, id) => {
|
||||||
|
setActionError(null);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||||
navigate(`/approvals/${id}?resolved=approved`);
|
navigate(`/approvals/${id}?resolved=approved`);
|
||||||
},
|
},
|
||||||
@@ -175,6 +240,7 @@ export function Inbox() {
|
|||||||
const rejectMutation = useMutation({
|
const rejectMutation = useMutation({
|
||||||
mutationFn: (id: string) => approvalsApi.reject(id),
|
mutationFn: (id: string) => approvalsApi.reject(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
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 hasRunFailures = failedRuns.length > 0;
|
||||||
const showAggregateAgentError =
|
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
|
||||||
!!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
|
const showBudgetAlert =
|
||||||
const hasAlerts =
|
|
||||||
!!dashboard &&
|
!!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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isApprovalsLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
<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>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
|
|
||||||
{!isApprovalsLoading && !hasContent && (
|
{!isLoading && visibleSections.length === 0 && (
|
||||||
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
<EmptyState
|
||||||
|
icon={InboxIcon}
|
||||||
|
message={tab === "new" ? "You're all caught up!" : "No inbox items match these filters."}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending Approvals */}
|
{showApprovalsSection && (
|
||||||
{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 && (
|
|
||||||
<>
|
<>
|
||||||
{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>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Failed Runs
|
Failed Runs
|
||||||
@@ -268,9 +518,11 @@ export function Inbox() {
|
|||||||
<span className="rounded-md bg-red-500/20 p-1.5">
|
<span className="rounded-md bg-red-500/20 p-1.5">
|
||||||
<XCircle className="h-4 w-4 text-red-400" />
|
<XCircle className="h-4 w-4 text-red-400" />
|
||||||
</span>
|
</span>
|
||||||
{linkedAgentName
|
{linkedAgentName ? (
|
||||||
? <Identity name={linkedAgentName} size="sm" />
|
<Identity name={linkedAgentName} size="sm" />
|
||||||
: <span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>}
|
) : (
|
||||||
|
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
|
||||||
|
)}
|
||||||
<StatusBadge status={run.status} />
|
<StatusBadge status={run.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
@@ -282,10 +534,12 @@ export function Inbox() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2.5"
|
className="h-8 px-2.5"
|
||||||
onClick={() => navigate(`/agents/${run.agentId}/runs/${run.id}`)}
|
asChild
|
||||||
>
|
>
|
||||||
Open run
|
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
|
||||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
Open run
|
||||||
|
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,13 +550,12 @@ export function Inbox() {
|
|||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<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>
|
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
|
||||||
{issue ? (
|
{issue ? (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
className="truncate text-muted-foreground transition-colors hover:text-foreground"
|
className="truncate text-muted-foreground transition-colors hover:text-foreground no-underline"
|
||||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
|
||||||
>
|
>
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
|
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
|
||||||
</button>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
|
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
|
||||||
@@ -318,61 +571,57 @@ export function Inbox() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alerts */}
|
{showAlertsSection && (
|
||||||
{hasAlerts && (
|
|
||||||
<>
|
<>
|
||||||
{(hasActionableApprovals || hasRunFailures) && <Separator />}
|
{showSeparatorBefore("alerts") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Alerts
|
Alerts
|
||||||
</h3>
|
</h3>
|
||||||
<div className="divide-y divide-border border border-border">
|
<div className="divide-y divide-border border border-border">
|
||||||
{showAggregateAgentError && (
|
{showAggregateAgentError && (
|
||||||
<div
|
<Link
|
||||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
to="/agents"
|
||||||
onClick={() => navigate("/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" />
|
<AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
|
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
|
||||||
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{dashboard!.costs.monthBudgetCents > 0 && dashboard!.costs.monthUtilizationPercent >= 80 && (
|
{showBudgetAlert && (
|
||||||
<div
|
<Link
|
||||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
to="/costs"
|
||||||
onClick={() => navigate("/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" />
|
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Budget at{" "}
|
Budget at{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "}
|
||||||
{dashboard!.costs.monthUtilizationPercent}%
|
|
||||||
</span>{" "}
|
|
||||||
utilization this month
|
utilization this month
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stale Work */}
|
{showStaleSection && (
|
||||||
{hasStale && (
|
|
||||||
<>
|
<>
|
||||||
{(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
|
{showSeparatorBefore("stale_work") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Stale Work
|
Stale Work
|
||||||
</h3>
|
</h3>
|
||||||
<div className="divide-y divide-border border border-border">
|
<div className="divide-y divide-border border border-border">
|
||||||
{staleIssues.map((issue) => (
|
{staleIssues.map((issue) => (
|
||||||
<div
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
onClick={() => navigate(`/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" />
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<PriorityIcon priority={issue.priority} />
|
<PriorityIcon priority={issue.priority} />
|
||||||
@@ -381,16 +630,21 @@ export function Inbox() {
|
|||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||||
{issue.assigneeAgentId && (() => {
|
{issue.assigneeAgentId &&
|
||||||
const name = agentName(issue.assigneeAgentId);
|
(() => {
|
||||||
return name
|
const name = agentName(issue.assigneeAgentId);
|
||||||
? <Identity name={name} size="sm" />
|
return name ? (
|
||||||
: <span className="font-mono text-xs text-muted-foreground">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
<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">
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
updated {timeAgo(issue.updatedAt)}
|
updated {timeAgo(issue.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [projectOpen, setProjectOpen] = useState(false);
|
|
||||||
const [projectSearch, setProjectSearch] = useState("");
|
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | 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>
|
<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(""); }}>
|
{issue.projectId ? (
|
||||||
<PopoverTrigger asChild>
|
<Link
|
||||||
<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">
|
to={`/projects/${issue.projectId}`}
|
||||||
<Hexagon className="h-3 w-3 shrink-0" />
|
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"
|
||||||
{issue.projectId
|
>
|
||||||
? ((projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8))
|
<Hexagon className="h-3 w-3 shrink-0" />
|
||||||
: <span className="opacity-50">No project</span>
|
{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}
|
||||||
}
|
</Link>
|
||||||
</button>
|
) : (
|
||||||
</PopoverTrigger>
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||||
<PopoverContent className="w-52 p-1" align="start">
|
<Hexagon className="h-3 w-3 shrink-0" />
|
||||||
<input
|
No project
|
||||||
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"
|
</span>
|
||||||
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>
|
|
||||||
|
|
||||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -591,6 +555,10 @@ export function IssueDetail() {
|
|||||||
onAdd={async (body, reopen) => {
|
onAdd={async (body, reopen) => {
|
||||||
await addComment.mutateAsync({ body, reopen });
|
await addComment.mutateAsync({ body, reopen });
|
||||||
}}
|
}}
|
||||||
|
imageUploadHandler={async (file) => {
|
||||||
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
|
return attachment.contentPath;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{childIssues.length > 0 && (
|
{childIssues.length > 0 && (
|
||||||
|
|||||||
@@ -1,69 +1,19 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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 { EmptyState } from "../components/EmptyState";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { Button } from "@/components/ui/button";
|
import { CircleDot } from "lucide-react";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Issues() {
|
export function Issues() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "active";
|
|
||||||
const tab = parseIssueTab(pathSegment);
|
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
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) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<IssuesList
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
issues={issues ?? []}
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
isLoading={isLoading}
|
||||||
<PageTabBar items={[...issueTabItems]} value={tab} onValueChange={(v) => setTab(v as TabFilter)} />
|
error={error as Error | null}
|
||||||
</Tabs>
|
agents={agents}
|
||||||
<Button size="sm" onClick={() => openNewIssue()}>
|
liveIssueIds={liveIssueIds}
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
viewStateKey="paperclip:issues-view"
|
||||||
New Issue
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -15,7 +14,6 @@ import { ListTodo } from "lucide-react";
|
|||||||
export function MyIssues() {
|
export function MyIssues() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "My Issues" }]);
|
setBreadcrumbs([{ label: "My Issues" }]);
|
||||||
@@ -52,7 +50,7 @@ export function MyIssues() {
|
|||||||
key={issue.id}
|
key={issue.id}
|
||||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
title={issue.title}
|
title={issue.title}
|
||||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
to={`/issues/${issue.identifier ?? issue.id}`}}
|
||||||
leading={
|
leading={
|
||||||
<>
|
<>
|
||||||
<PriorityIcon priority={issue.priority} />
|
<PriorityIcon priority={issue.priority} />
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function ColorPicker({
|
|||||||
aria-label="Change project color"
|
aria-label="Change project color"
|
||||||
/>
|
/>
|
||||||
{open && (
|
{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">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{PROJECT_COLORS.map((color) => (
|
{PROJECT_COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
@@ -252,11 +252,13 @@ export function ProjectDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<ColorPicker
|
<div className="h-7 flex items-center">
|
||||||
currentColor={project.color ?? "#6366f1"}
|
<ColorPicker
|
||||||
onSelect={(color) => updateProject.mutate({ color })}
|
currentColor={project.color ?? "#6366f1"}
|
||||||
/>
|
onSelect={(color) => updateProject.mutate({ color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
value={project.name}
|
value={project.name}
|
||||||
onSave={(name) => updateProject.mutate({ name })}
|
onSave={(name) => updateProject.mutate({ name })}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -17,7 +16,6 @@ export function Projects() {
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialog();
|
const { openNewProject } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Projects" }]);
|
setBreadcrumbs([{ label: "Projects" }]);
|
||||||
@@ -36,7 +34,7 @@ export function Projects() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-end">
|
<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" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add Project
|
Add Project
|
||||||
</Button>
|
</Button>
|
||||||
@@ -61,7 +59,7 @@ export function Projects() {
|
|||||||
key={project.id}
|
key={project.id}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
subtitle={project.description ?? undefined}
|
subtitle={project.description ?? undefined}
|
||||||
onClick={() => navigate(`/projects/${project.id}`)}
|
to={`/projects/${project.id}`}
|
||||||
trailing={
|
trailing={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{project.targetDate && (
|
{project.targetDate && (
|
||||||
|
|||||||
Reference in New Issue
Block a user