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