Add configuration tabs to project and agent pages

This commit is contained in:
Dotta
2026-03-10 09:08:20 -05:00
parent b83a87f42f
commit 6186eba098
3 changed files with 72 additions and 111 deletions

View File

@@ -121,6 +121,7 @@ function boardRoutes() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} /> <Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} /> <Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} /> <Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} /> <Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} /> <Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} /> <Route path="issues/active" element={<Navigate to="/issues" replace />} />
@@ -235,6 +236,7 @@ export function App() {
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}> <Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()} {boardRoutes()}
</Route> </Route>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -14,6 +14,7 @@ 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 { AgentConfigForm } from "../components/AgentConfigForm"; import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
import { getUIAdapter, buildTranscript } from "../adapters"; import { getUIAdapter, buildTranscript } from "../adapters";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
@@ -28,6 +29,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -53,7 +55,6 @@ import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
ArrowLeft, ArrowLeft,
Settings,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
@@ -173,12 +174,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior }); container.scrollTo({ top: container.scrollHeight, behavior });
} }
type AgentDetailView = "overview" | "configure" | "runs"; type AgentDetailView = "dashboard" | "configuration" | "runs";
function parseAgentDetailView(value: string | null): AgentDetailView { function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configure"; if (value === "configure" || value === "configuration") return "configuration";
if (value === "runs") return value; if (value === "runs") return value;
return "overview"; return "dashboard";
} }
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) { function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
@@ -304,17 +305,18 @@ export function AgentDetail() {
useEffect(() => { useEffect(() => {
if (!agent) return; if (!agent) return;
if (routeAgentRef === canonicalAgentRef) return;
if (urlRunId) { if (urlRunId) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); if (routeAgentRef !== canonicalAgentRef) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
}
return; return;
} }
if (urlTab) { const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true }); if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return; return;
} }
navigate(`/agents/${canonicalAgentRef}`, { replace: true }); }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
useEffect(() => { useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return; if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -397,17 +399,19 @@ export function AgentDetail() {
{ label: "Agents", href: "/agents" }, { label: "Agents", href: "/agents" },
]; ];
const agentName = agent?.name ?? routeAgentRef ?? "Agent"; const agentName = agent?.name ?? routeAgentRef ?? "Agent";
if (activeView === "overview" && !urlRunId) { if (activeView === "dashboard" && !urlRunId) {
crumbs.push({ label: agentName }); crumbs.push({ label: agentName });
} else { } else {
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` }); crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` });
if (urlRunId) { if (urlRunId) {
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configure") { } else if (activeView === "configuration") {
crumbs.push({ label: "Configure" }); crumbs.push({ label: "Configuration" });
} else if (activeView === "runs") { } else if (activeView === "runs") {
crumbs.push({ label: "Runs" }); crumbs.push({ label: "Runs" });
} else {
crumbs.push({ label: "Dashboard" });
} }
} }
setBreadcrumbs(crumbs); setBreadcrumbs(crumbs);
@@ -416,7 +420,7 @@ export function AgentDetail() {
useEffect(() => { useEffect(() => {
closePanel(); closePanel();
return () => closePanel(); return () => closePanel();
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [closePanel]);
useBeforeUnload( useBeforeUnload(
useCallback((event) => { useCallback((event) => {
@@ -429,8 +433,11 @@ export function AgentDetail() {
if (isLoading) return <PageSkeleton variant="detail" />; if (isLoading) return <PageSkeleton variant="detail" />;
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 (!agent) return null; if (!agent) return null;
if (!urlRunId && !urlTab) {
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
}
const isPendingApproval = agent.status === "pending_approval"; const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeView === "configure" && configDirty; const showConfigActionBar = activeView === "configuration" && configDirty;
return ( return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}> <div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -514,16 +521,6 @@ export function AgentDetail() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end"> <PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
navigate(`/agents/${canonicalAgentRef}/configure`);
setMoreOpen(false);
}}
>
<Settings className="h-3 w-3" />
Configure Agent
</button>
<button <button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50" className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => { onClick={() => {
@@ -559,6 +556,22 @@ export function AgentDetail() {
</div> </div>
</div> </div>
{!urlRunId && (
<Tabs
value={activeView === "configuration" ? "configuration" : "dashboard"}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
]}
value={activeView === "configuration" ? "configuration" : "dashboard"}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
/>
</Tabs>
)}
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && ( {isPendingApproval && (
<p className="text-sm text-amber-500"> <p className="text-sm text-amber-500">
@@ -623,7 +636,7 @@ export function AgentDetail() {
)} )}
{/* View content */} {/* View content */}
{activeView === "overview" && ( {activeView === "dashboard" && (
<AgentOverview <AgentOverview
agent={agent} agent={agent}
runs={heartbeats ?? []} runs={heartbeats ?? []}
@@ -636,7 +649,7 @@ export function AgentDetail() {
/> />
)} )}
{activeView === "configure" && ( {activeView === "configuration" && (
<AgentConfigurePage <AgentConfigurePage
agent={agent} agent={agent}
agentId={agent.id} agentId={agent.id}
@@ -824,7 +837,6 @@ function AgentOverview({
{/* Configuration Summary */} {/* Configuration Summary */}
<ConfigSummary <ConfigSummary
agent={agent} agent={agent}
agentRouteId={agentRouteId}
reportsToAgent={reportsToAgent} reportsToAgent={reportsToAgent}
directReports={directReports} directReports={directReports}
/> />
@@ -838,12 +850,10 @@ function AgentOverview({
function ConfigSummary({ function ConfigSummary({
agent, agent,
agentRouteId,
reportsToAgent, reportsToAgent,
directReports, directReports,
}: { }: {
agent: Agent; agent: Agent;
agentRouteId: string;
reportsToAgent: Agent | null; reportsToAgent: Agent | null;
directReports: Agent[]; directReports: Agent[];
}) { }) {
@@ -852,16 +862,7 @@ function ConfigSummary({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <h3 className="text-sm font-medium">Configuration</h3>
<h3 className="text-sm font-medium">Configuration</h3>
<Link
to={`/agents/${agentRouteId}/configure`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
>
<Settings className="h-3 w-3" />
Manage &rarr;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border border-border rounded-lg p-4 space-y-3"> <div className="border border-border rounded-lg p-4 space-y-3">
<h4 className="text-xs text-muted-foreground font-medium">Agent Details</h4> <h4 className="text-xs text-muted-foreground font-medium">Agent Details</h4>

View File

@@ -16,15 +16,13 @@ import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { IssuesList } from "../components/IssuesList"; import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils"; import { projectRouteRef, cn } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SlidersHorizontal } from "lucide-react";
/* ── Top-level tab types ── */ /* ── Top-level tab types ── */
type ProjectTab = "overview" | "list"; type ProjectTab = "overview" | "list" | "configuration";
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
const segments = pathname.split("/").filter(Boolean); const segments = pathname.split("/").filter(Boolean);
@@ -32,6 +30,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
const tab = segments[projectsIdx + 2]; const tab = segments[projectsIdx + 2];
if (tab === "overview") return "overview"; if (tab === "overview") return "overview";
if (tab === "configuration") return "configuration";
if (tab === "issues") return "list"; if (tab === "issues") return "list";
return null; return null;
} }
@@ -198,9 +197,8 @@ export function ProjectDetail() {
filter?: string; filter?: string;
}>(); }>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -264,6 +262,10 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return; return;
} }
if (activeTab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
return;
}
if (activeTab === "list") { if (activeTab === "list") {
if (filter) { if (filter) {
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
@@ -276,11 +278,9 @@ export function ProjectDetail() {
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]); }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
useEffect(() => { useEffect(() => {
if (project) { closePanel();
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
}
return () => closePanel(); return () => closePanel();
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps }, [closePanel]);
// Redirect bare /projects/:id to /projects/:id/issues // Redirect bare /projects/:id to /projects/:id/issues
if (routeProjectRef && activeTab === null) { if (routeProjectRef && activeTab === null) {
@@ -294,6 +294,8 @@ export function ProjectDetail() {
const handleTabChange = (tab: ProjectTab) => { const handleTabChange = (tab: ProjectTab) => {
if (tab === "overview") { if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`); navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`);
} else { } else {
navigate(`/projects/${canonicalProjectRef}/issues`); navigate(`/projects/${canonicalProjectRef}/issues`);
} }
@@ -314,54 +316,20 @@ export function ProjectDetail() {
as="h2" as="h2"
className="text-xl font-bold" className="text-xl font-bold"
/> />
<Button
variant="ghost"
size="icon-xs"
className="ml-auto md:hidden shrink-0"
onClick={() => setMobilePropsOpen(true)}
title="Properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"shrink-0 ml-auto transition-opacity duration-200 hidden md:flex",
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
)}
onClick={() => setPanelVisible(true)}
title="Show properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
</div> </div>
{/* Top-level project tabs */} <Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<div className="flex items-center gap-1 border-b border-border"> <PageTabBar
<button items={[
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${ { value: "overview", label: "Overview" },
activeTab === "overview" { value: "list", label: "List" },
? "border-foreground text-foreground" { value: "configuration", label: "Configuration" },
: "border-transparent text-muted-foreground hover:text-foreground" ]}
}`} value={activeTab ?? "list"}
onClick={() => handleTabChange("overview")} onValueChange={(value) => handleTabChange(value as ProjectTab)}
> />
Overview </Tabs>
</button>
<button
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${
activeTab === "list"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleTabChange("list")}
>
List
</button>
</div>
{/* Tab content */}
{activeTab === "overview" && ( {activeTab === "overview" && (
<OverviewContent <OverviewContent
project={project} project={project}
@@ -377,19 +345,9 @@ export function ProjectDetail() {
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} /> <ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
)} )}
{/* Mobile properties drawer */} {activeTab === "configuration" && (
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}> <ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]"> )}
<SheetHeader>
<SheetTitle className="text-sm">Properties</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div> </div>
); );
} }