feat(ui): reconcile backup UI changes with current routing and interaction features
This commit is contained in:
@@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { ActivityRow } from "../components/ActivityRow";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -84,6 +85,10 @@ export function Activity() {
|
||||
return <EmptyState icon={History} message="Select a company to view activity." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
const filtered =
|
||||
data && filter !== "all"
|
||||
? data.filter((e) => e.entityType === filter)
|
||||
@@ -111,7 +116,6 @@ export function Activity() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{filtered && filtered.length === 0 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom";
|
||||
import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -22,6 +22,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,7 +55,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState } from "@paperclip/shared";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||
@@ -223,8 +225,13 @@ function asNonEmptyString(value: unknown): string | null {
|
||||
}
|
||||
|
||||
export function AgentDetail() {
|
||||
const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
|
||||
companyPrefix?: string;
|
||||
agentId: string;
|
||||
tab?: string;
|
||||
runId?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -238,68 +245,101 @@ export function AgentDetail() {
|
||||
const saveConfigActionRef = useRef<(() => void) | null>(null);
|
||||
const cancelConfigActionRef = useRef<(() => void) | null>(null);
|
||||
const { isMobile } = useSidebar();
|
||||
const routeAgentRef = agentId ?? "";
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||||
}, [companies, companyPrefix]);
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId));
|
||||
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
||||
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
||||
|
||||
const { data: agent, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.detail(agentId!),
|
||||
queryFn: () => agentsApi.get(agentId!),
|
||||
enabled: !!agentId,
|
||||
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
|
||||
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
|
||||
enabled: canFetchAgent,
|
||||
});
|
||||
const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
|
||||
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
|
||||
const agentLookupRef = agent?.id ?? routeAgentRef;
|
||||
|
||||
const { data: runtimeState } = useQuery({
|
||||
queryKey: queryKeys.agents.runtimeState(agentId!),
|
||||
queryFn: () => agentsApi.runtimeState(agentId!),
|
||||
enabled: !!agentId,
|
||||
queryKey: queryKeys.agents.runtimeState(agentLookupRef),
|
||||
queryFn: () => agentsApi.runtimeState(agentLookupRef, resolvedCompanyId ?? undefined),
|
||||
enabled: Boolean(agentLookupRef),
|
||||
});
|
||||
|
||||
const { data: heartbeats } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId),
|
||||
enabled: !!selectedCompanyId && !!agentId,
|
||||
queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined),
|
||||
queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined),
|
||||
enabled: !!resolvedCompanyId && !!agent?.id,
|
||||
});
|
||||
|
||||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
queryKey: queryKeys.issues.list(resolvedCompanyId!),
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
|
||||
const { data: allAgents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
queryKey: queryKeys.agents.list(resolvedCompanyId!),
|
||||
queryFn: () => agentsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
|
||||
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agent?.id);
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
const mobileLiveRun = useMemo(
|
||||
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
|
||||
[heartbeats],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
if (routeAgentRef === canonicalAgentRef) return;
|
||||
if (urlRunId) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (urlTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/agents/${canonicalAgentRef}`, { replace: true });
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||||
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
|
||||
if (!agentId) return Promise.reject(new Error("No agent ID"));
|
||||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agentId);
|
||||
case "pause": return agentsApi.pause(agentId);
|
||||
case "resume": return agentsApi.resume(agentId);
|
||||
case "terminate": return agentsApi.terminate(agentId);
|
||||
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(selectedCompanyId, agentId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
if (agent?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
}
|
||||
}
|
||||
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
|
||||
navigate(`/agents/${agentId}/runs/${(data as HeartbeatRun).id}`);
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -308,21 +348,23 @@ export function AgentDetail() {
|
||||
});
|
||||
|
||||
const updateIcon = useMutation({
|
||||
mutationFn: (icon: string) => agentsApi.update(agentId!, { icon }),
|
||||
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey),
|
||||
mutationFn: (taskKey: string | null) =>
|
||||
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
@@ -331,12 +373,13 @@ export function AgentDetail() {
|
||||
|
||||
const updatePermissions = useMutation({
|
||||
mutationFn: (canCreateAgents: boolean) =>
|
||||
agentsApi.updatePermissions(agentId!, { canCreateAgents }),
|
||||
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -348,13 +391,13 @@ export function AgentDetail() {
|
||||
const crumbs: { label: string; href?: string }[] = [
|
||||
{ label: "Agents", href: "/agents" },
|
||||
];
|
||||
const agentName = agent?.name ?? agentId ?? "Agent";
|
||||
const agentName = agent?.name ?? routeAgentRef ?? "Agent";
|
||||
if (activeView === "overview" && !urlRunId) {
|
||||
crumbs.push({ label: agentName });
|
||||
} else {
|
||||
crumbs.push({ label: agentName, href: `/agents/${agentId}` });
|
||||
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` });
|
||||
if (urlRunId) {
|
||||
crumbs.push({ label: "Runs", href: `/agents/${agentId}/runs` });
|
||||
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||
} else if (activeView === "configure") {
|
||||
crumbs.push({ label: "Configure" });
|
||||
@@ -363,7 +406,7 @@ export function AgentDetail() {
|
||||
}
|
||||
}
|
||||
setBreadcrumbs(crumbs);
|
||||
}, [setBreadcrumbs, agent, agentId, activeView, urlRunId]);
|
||||
}, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
closePanel();
|
||||
@@ -378,7 +421,7 @@ export function AgentDetail() {
|
||||
}, [configDirty]),
|
||||
);
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
@@ -409,7 +452,7 @@ export function AgentDetail() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Assign Task</span>
|
||||
@@ -447,7 +490,7 @@ export function AgentDetail() {
|
||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||
{mobileLiveRun && (
|
||||
<Link
|
||||
to={`/agents/${agent.id}/runs/${mobileLiveRun.id}`}
|
||||
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
|
||||
className="sm:hidden 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"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -466,6 +509,16 @@ export function AgentDetail() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
@@ -532,7 +585,7 @@ export function AgentDetail() {
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving..." : "Save"}
|
||||
{configSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,7 +611,7 @@ export function AgentDetail() {
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving..." : "Save"}
|
||||
{configSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,14 +626,16 @@ export function AgentDetail() {
|
||||
runtimeState={runtimeState}
|
||||
reportsToAgent={reportsToAgent ?? null}
|
||||
directReports={directReports}
|
||||
agentId={agentId!}
|
||||
agentId={agent.id}
|
||||
agentRouteId={canonicalAgentRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "configure" && (
|
||||
<AgentConfigurePage
|
||||
agent={agent}
|
||||
agentId={agentId!}
|
||||
agentId={agent.id}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
onDirtyChange={setConfigDirty}
|
||||
onSaveActionChange={setSaveConfigAction}
|
||||
onCancelActionChange={setCancelConfigAction}
|
||||
@@ -592,8 +647,9 @@ export function AgentDetail() {
|
||||
{activeView === "runs" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
companyId={selectedCompanyId!}
|
||||
agentId={agentId!}
|
||||
companyId={resolvedCompanyId!}
|
||||
agentId={agent.id}
|
||||
agentRouteId={canonicalAgentRef}
|
||||
selectedRunId={urlRunId ?? null}
|
||||
adapterType={agent.adapterType}
|
||||
/>
|
||||
@@ -631,7 +687,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -649,10 +705,13 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"border rounded-lg p-4 space-y-2",
|
||||
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
|
||||
)}>
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${run.id}`}
|
||||
className={cn(
|
||||
"block border rounded-lg p-4 space-y-2 w-full no-underline transition-colors hover:bg-muted/50 cursor-pointer",
|
||||
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||
<StatusBadge status={run.status} />
|
||||
@@ -674,7 +733,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
<MarkdownBody className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{summary}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -689,6 +748,7 @@ function AgentOverview({
|
||||
reportsToAgent,
|
||||
directReports,
|
||||
agentId,
|
||||
agentRouteId,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runs: HeartbeatRun[];
|
||||
@@ -697,11 +757,12 @@ function AgentOverview({
|
||||
reportsToAgent: Agent | null;
|
||||
directReports: Agent[];
|
||||
agentId: string;
|
||||
agentRouteId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Latest Run */}
|
||||
<LatestRunCard runs={runs} agentId={agentId} />
|
||||
<LatestRunCard runs={runs} agentId={agentRouteId} />
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@@ -758,7 +819,7 @@ function AgentOverview({
|
||||
{/* Configuration Summary */}
|
||||
<ConfigSummary
|
||||
agent={agent}
|
||||
agentId={agentId}
|
||||
agentRouteId={agentRouteId}
|
||||
reportsToAgent={reportsToAgent}
|
||||
directReports={directReports}
|
||||
/>
|
||||
@@ -772,12 +833,12 @@ function AgentOverview({
|
||||
|
||||
function ConfigSummary({
|
||||
agent,
|
||||
agentId,
|
||||
agentRouteId,
|
||||
reportsToAgent,
|
||||
directReports,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agentId: string;
|
||||
agentRouteId: string;
|
||||
reportsToAgent: Agent | null;
|
||||
directReports: Agent[];
|
||||
}) {
|
||||
@@ -789,7 +850,7 @@ function ConfigSummary({
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Configuration</h3>
|
||||
<Link
|
||||
to={`/agents/${agentId}/configure`}
|
||||
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" />
|
||||
@@ -835,7 +896,7 @@ function ConfigSummary({
|
||||
<SummaryRow label="Reports to">
|
||||
{reportsToAgent ? (
|
||||
<Link
|
||||
to={`/agents/${reportsToAgent.id}`}
|
||||
to={`/agents/${agentRouteRef(reportsToAgent)}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
@@ -852,7 +913,7 @@ function ConfigSummary({
|
||||
{directReports.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
to={`/agents/${r.id}`}
|
||||
to={`/agents/${agentRouteRef(r)}`}
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -966,6 +1027,7 @@ function CostsSection({
|
||||
function AgentConfigurePage({
|
||||
agent,
|
||||
agentId,
|
||||
companyId,
|
||||
onDirtyChange,
|
||||
onSaveActionChange,
|
||||
onCancelActionChange,
|
||||
@@ -974,6 +1036,7 @@ function AgentConfigurePage({
|
||||
}: {
|
||||
agent: Agent;
|
||||
agentId: string;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
@@ -985,11 +1048,11 @@ function AgentConfigurePage({
|
||||
|
||||
const { data: configRevisions } = useQuery({
|
||||
queryKey: queryKeys.agents.configRevisions(agent.id),
|
||||
queryFn: () => agentsApi.listConfigRevisions(agent.id),
|
||||
queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
|
||||
});
|
||||
|
||||
const rollbackConfig = useMutation({
|
||||
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId),
|
||||
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
@@ -1005,10 +1068,11 @@ function AgentConfigurePage({
|
||||
onCancelActionChange={onCancelActionChange}
|
||||
onSavingChange={onSavingChange}
|
||||
updatePermissions={updatePermissions}
|
||||
companyId={companyId}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">API Keys</h3>
|
||||
<KeysTab agentId={agentId} />
|
||||
<KeysTab agentId={agentId} companyId={companyId} />
|
||||
</div>
|
||||
|
||||
{/* Configuration Revisions — collapsible at the bottom */}
|
||||
@@ -1069,6 +1133,7 @@ function AgentConfigurePage({
|
||||
|
||||
function ConfigurationTab({
|
||||
agent,
|
||||
companyId,
|
||||
onDirtyChange,
|
||||
onSaveActionChange,
|
||||
onCancelActionChange,
|
||||
@@ -1076,6 +1141,7 @@ function ConfigurationTab({
|
||||
updatePermissions,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
@@ -1090,7 +1156,7 @@ function ConfigurationTab({
|
||||
});
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data),
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
@@ -1190,7 +1256,21 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
);
|
||||
}
|
||||
|
||||
function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) {
|
||||
function RunsTab({
|
||||
runs,
|
||||
companyId,
|
||||
agentId,
|
||||
agentRouteId,
|
||||
selectedRunId,
|
||||
adapterType,
|
||||
}: {
|
||||
runs: HeartbeatRun[];
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentRouteId: string;
|
||||
selectedRunId: string | null;
|
||||
adapterType: string;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
if (runs.length === 0) {
|
||||
@@ -1212,20 +1292,20 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
||||
return (
|
||||
<div className="space-y-3 min-w-0 overflow-x-hidden">
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs`}
|
||||
to={`/agents/${agentRouteId}/runs`}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to runs
|
||||
</Link>
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} />
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-x-hidden">
|
||||
{sorted.map((run) => (
|
||||
<RunListItem key={run.id} run={run} isSelected={false} agentId={agentId} />
|
||||
<RunListItem key={run.id} run={run} isSelected={false} agentId={agentRouteId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1241,7 +1321,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
||||
)}>
|
||||
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
|
||||
{sorted.map((run) => (
|
||||
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentId} />
|
||||
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentRouteId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1249,7 +1329,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
||||
{/* Right: run detail — natural height, page scrolls */}
|
||||
{selectedRun && (
|
||||
<div className="flex-1 min-w-0 pl-4">
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} />
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1258,7 +1338,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
||||
|
||||
/* ---- Run Detail (expanded) ---- */
|
||||
|
||||
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||||
function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const metrics = runMetrics(run);
|
||||
@@ -1299,7 +1379,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
triggerDetail: "manual",
|
||||
reason: "resume_process_lost_run",
|
||||
payload: resumePayload,
|
||||
});
|
||||
}, run.companyId);
|
||||
if (!("id" in result)) {
|
||||
throw new Error("Resume request was skipped because the agent is not currently invokable.");
|
||||
}
|
||||
@@ -1307,7 +1387,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
},
|
||||
onSuccess: (resumedRun) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
|
||||
navigate(`/agents/${run.agentId}/runs/${resumedRun.id}`);
|
||||
navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1323,7 +1403,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
const clearSessionsForTouchedIssues = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (touchedIssueIds.length === 0) return 0;
|
||||
await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId)));
|
||||
await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId, run.companyId)));
|
||||
return touchedIssueIds.length;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -1334,7 +1414,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
});
|
||||
|
||||
const runClaudeLogin = useMutation({
|
||||
mutationFn: () => agentsApi.loginWithClaude(run.agentId),
|
||||
mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
|
||||
onSuccess: (data) => {
|
||||
setClaudeLoginResult(data);
|
||||
},
|
||||
@@ -1386,7 +1466,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
onClick={() => cancelRun.mutate()}
|
||||
disabled={cancelRun.isPending}
|
||||
>
|
||||
{cancelRun.isPending ? "Cancelling..." : "Cancel"}
|
||||
{cancelRun.isPending ? "Cancelling…" : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
{canResumeLostRun && (
|
||||
@@ -1398,7 +1478,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
disabled={resumeRun.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
{resumeRun.isPending ? "Resuming..." : "Resume"}
|
||||
{resumeRun.isPending ? "Resuming…" : "Resume"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1898,6 +1978,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{adapterInvokePayload.commandNotes
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.map((note, idx) => (
|
||||
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
@@ -2147,7 +2241,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
/* ---- Keys Tab ---- */
|
||||
|
||||
function KeysTab({ agentId }: { agentId: string }) {
|
||||
function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
@@ -2156,11 +2250,11 @@ function KeysTab({ agentId }: { agentId: string }) {
|
||||
|
||||
const { data: keys, isLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.keys(agentId),
|
||||
queryFn: () => agentsApi.listKeys(agentId),
|
||||
queryFn: () => agentsApi.listKeys(agentId, companyId),
|
||||
});
|
||||
|
||||
const createKey = useMutation({
|
||||
mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"),
|
||||
mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId),
|
||||
onSuccess: (data) => {
|
||||
setNewToken(data.token);
|
||||
setTokenVisible(true);
|
||||
@@ -2170,7 +2264,7 @@ function KeysTab({ agentId }: { agentId: string }) {
|
||||
});
|
||||
|
||||
const revokeKey = useMutation({
|
||||
mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId),
|
||||
mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Link, useNavigate, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -12,7 +12,8 @@ import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { relativeTime, cn } from "../lib/utils";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -121,6 +122,10 @@ export function Agents() {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
||||
|
||||
@@ -204,7 +209,6 @@ export function Agents() {
|
||||
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{agents && agents.length === 0 && (
|
||||
@@ -225,7 +229,7 @@ export function Agents() {
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={`/agents/${agent.id}`}
|
||||
to={agentUrl(agent)}
|
||||
leading={
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
@@ -238,7 +242,7 @@ export function Agents() {
|
||||
<span className="sm:hidden">
|
||||
{liveRunByAgent.has(agent.id) ? (
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
agentRef={agentRouteRef(agent)}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
@@ -249,7 +253,7 @@ export function Agents() {
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
{liveRunByAgent.has(agent.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
agentRef={agentRouteRef(agent)}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
@@ -320,7 +324,7 @@ function OrgTreeNode({
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<Link
|
||||
to={`/agents/${node.id}`}
|
||||
to={agent ? agentUrl(agent) : `/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">
|
||||
@@ -337,7 +341,7 @@ function OrgTreeNode({
|
||||
<span className="sm:hidden">
|
||||
{liveRunByAgent.has(node.id) ? (
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
agentRef={agent ? agentRouteRef(agent) : node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
/>
|
||||
@@ -348,7 +352,7 @@ function OrgTreeNode({
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
{liveRunByAgent.has(node.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
agentRef={agent ? agentRouteRef(agent) : node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
/>
|
||||
@@ -381,17 +385,17 @@ function OrgTreeNode({
|
||||
}
|
||||
|
||||
function LiveRunIndicator({
|
||||
agentId,
|
||||
agentRef,
|
||||
runId,
|
||||
liveCount,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentRef: string;
|
||||
runId: string;
|
||||
liveCount: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${runId}`}
|
||||
to={`/agents/${agentRef}/runs/${runId}`}
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -9,6 +9,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
||||
@@ -17,7 +18,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
|
||||
|
||||
export function ApprovalDetail() {
|
||||
const { approvalId } = useParams<{ approvalId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -31,6 +32,7 @@ export function ApprovalDetail() {
|
||||
queryFn: () => approvalsApi.get(approvalId!),
|
||||
enabled: !!approvalId,
|
||||
});
|
||||
const resolvedCompanyId = approval?.companyId ?? selectedCompanyId;
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.approvals.comments(approvalId!),
|
||||
@@ -45,11 +47,16 @@ export function ApprovalDetail() {
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
||||
queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
||||
enabled: !!(approval?.companyId ?? selectedCompanyId),
|
||||
queryKey: queryKeys.agents.list(resolvedCompanyId ?? ""),
|
||||
queryFn: () => agentsApi.list(resolvedCompanyId ?? ""),
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!approval?.companyId || approval.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(approval.companyId, { source: "route_sync" });
|
||||
}, [approval?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
const agentNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const agent of agents ?? []) map.set(agent.id, agent.name);
|
||||
@@ -134,7 +141,7 @@ export function ApprovalDetail() {
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (!approval) return <p className="text-sm text-muted-foreground">Approval not found.</p>;
|
||||
|
||||
const payload = approval.payload as Record<string, unknown>;
|
||||
@@ -346,7 +353,7 @@ export function ApprovalDetail() {
|
||||
onClick={() => addCommentMutation.mutate()}
|
||||
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
||||
>
|
||||
{addCommentMutation.isPending ? "Posting..." : "Post comment"}
|
||||
{addCommentMutation.isPending ? "Posting…" : "Post comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -11,6 +11,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
||||
type StatusFilter = "pending" | "all";
|
||||
|
||||
@@ -77,6 +78,10 @@ export function Approvals() {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="approvals" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -95,11 +100,10 @@ export function Approvals() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<ShieldCheck className="h-8 w-8 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
|
||||
@@ -59,83 +61,102 @@ export function AuthPage() {
|
||||
(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="fixed inset-0 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
</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>
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{/* Left half — form */}
|
||||
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
||||
<div className="w-full max-w-md mx-auto my-auto px-8 py-12">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Paperclip</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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");
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
{mode === "sign_in" ? "Create one" : "Sign in"}
|
||||
</button>
|
||||
{mode === "sign_up" && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Name</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Email</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
autoFocus={mode === "sign_in"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Password</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs 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-5 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>
|
||||
|
||||
{/* Right half — ASCII art animation (hidden on mobile) */}
|
||||
<div className="hidden md:block w-1/2 overflow-hidden">
|
||||
<AsciiArtAnimation />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Link, useParams, useSearchParams } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -117,7 +117,7 @@ export function BoardClaimPage() {
|
||||
onClick={() => claimMutation.mutate()}
|
||||
disabled={claimMutation.isPending}
|
||||
>
|
||||
{claimMutation.isPending ? "Claiming..." : "Claim ownership"}
|
||||
{claimMutation.isPending ? "Claiming…" : "Claim ownership"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -283,7 +283,7 @@ export function Companies() {
|
||||
onClick={() => deleteMutation.mutate(company.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatCents, formatTokens } from "../lib/utils";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
@@ -89,6 +90,10 @@ export function Costs() {
|
||||
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="costs" />;
|
||||
}
|
||||
|
||||
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||
|
||||
return (
|
||||
@@ -124,7 +129,6 @@ export function Costs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{data && (
|
||||
@@ -151,7 +155,7 @@ export function Costs() {
|
||||
{data.summary.budgetCents > 0 && (
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
className={`h-full rounded-full transition-[width,background-color] duration-150 ${
|
||||
data.summary.utilizationPercent > 90
|
||||
? "bg-red-400"
|
||||
: data.summary.utilizationPercent > 70
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
@@ -22,6 +22,7 @@ import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import type { Agent, Issue } from "@paperclip/shared";
|
||||
|
||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||
@@ -177,9 +178,12 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
<ActiveAgentsPanel companyId={selectedCompanyId!} />
|
||||
@@ -256,11 +260,11 @@ export function Dashboard() {
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Recent Activity */}
|
||||
{recentActivity.length > 0 && (
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
<div className="border border-border divide-y divide-border overflow-hidden">
|
||||
{recentActivity.map((event) => (
|
||||
<ActivityRow
|
||||
key={event.id}
|
||||
@@ -276,7 +280,7 @@ export function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Recent Tasks */}
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Tasks
|
||||
</h3>
|
||||
@@ -285,7 +289,7 @@ export function Dashboard() {
|
||||
<p className="text-sm text-muted-foreground">No tasks yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border divide-y divide-border">
|
||||
<div className="border border-border divide-y divide-border overflow-hidden">
|
||||
{recentIssues.slice(0, 10).map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
@@ -298,7 +302,7 @@ export function Dashboard() {
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 sm:truncate">
|
||||
<p className="min-w-0 flex-1 truncate">
|
||||
<span>{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
|
||||
@@ -1038,7 +1038,7 @@ export function DesignGuide() {
|
||||
</div>
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
className={`h-full rounded-full transition-[width,background-color] duration-150 ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -14,6 +14,8 @@ import { GoalTree } from "../components/GoalTree";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { projectUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus } from "lucide-react";
|
||||
@@ -21,7 +23,7 @@ import type { Goal, Project } from "@paperclip/shared";
|
||||
|
||||
export function GoalDetail() {
|
||||
const { goalId } = useParams<{ goalId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -36,19 +38,25 @@ export function GoalDetail() {
|
||||
queryFn: () => goalsApi.get(goalId!),
|
||||
enabled: !!goalId
|
||||
});
|
||||
const resolvedCompanyId = goal?.companyId ?? selectedCompanyId;
|
||||
|
||||
const { data: allGoals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId
|
||||
queryKey: queryKeys.goals.list(resolvedCompanyId!),
|
||||
queryFn: () => goalsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId
|
||||
});
|
||||
|
||||
const { data: allProjects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId
|
||||
queryKey: queryKeys.projects.list(resolvedCompanyId!),
|
||||
queryFn: () => projectsApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!goal?.companyId || goal.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(goal.companyId, { source: "route_sync" });
|
||||
}, [goal?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
const updateGoal = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
goalsApi.update(goalId!, data),
|
||||
@@ -56,9 +64,9 @@ export function GoalDetail() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.detail(goalId!)
|
||||
});
|
||||
if (selectedCompanyId) {
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId)
|
||||
queryKey: queryKeys.goals.list(resolvedCompanyId)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -66,9 +74,9 @@ export function GoalDetail() {
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(
|
||||
selectedCompanyId,
|
||||
resolvedCompanyId,
|
||||
file,
|
||||
`goals/${goalId ?? "draft"}`
|
||||
);
|
||||
@@ -102,8 +110,7 @@ export function GoalDetail() {
|
||||
return () => closePanel();
|
||||
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isLoading)
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!goal) return null;
|
||||
|
||||
@@ -176,7 +183,7 @@ export function GoalDetail() {
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
to={`/projects/${project.id}`}
|
||||
to={projectUrl(project)}
|
||||
trailing={<StatusBadge status={project.status} />}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalTree } from "../components/GoalTree";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Target, Plus } from "lucide-react";
|
||||
|
||||
@@ -29,9 +30,12 @@ export function Goals() {
|
||||
return <EmptyState icon={Target} message="Select a company to view goals." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{goals && goals.length === 0 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { accessApi } from "../api/access";
|
||||
@@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
@@ -208,7 +209,7 @@ function FailedRunCard({
|
||||
disabled={retryRun.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{retryRun.isPending ? "Retrying..." : "Retry"}
|
||||
{retryRun.isPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -562,7 +563,7 @@ export function Inbox() {
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
||||
{!allLoaded && visibleSections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
<PageSkeleton variant="inbox" />
|
||||
)}
|
||||
|
||||
{allLoaded && visibleSections.length === 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { healthApi } from "../api/health";
|
||||
@@ -154,12 +154,19 @@ export function InviteLandingPage() {
|
||||
claimSecret?: string;
|
||||
claimApiKeyPath?: string;
|
||||
onboarding?: Record<string, unknown>;
|
||||
diagnostics?: Array<{
|
||||
code: string;
|
||||
level: "info" | "warn";
|
||||
message: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
};
|
||||
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
|
||||
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
|
||||
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
|
||||
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
|
||||
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
|
||||
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
@@ -185,6 +192,19 @@ export function InviteLandingPage() {
|
||||
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
|
||||
</div>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Connectivity diagnostics</p>
|
||||
{diagnostics.map((diag, idx) => (
|
||||
<div key={`${diag.code}:${idx}`} className="space-y-0.5">
|
||||
<p className={diag.level === "warn" ? "text-amber-600 dark:text-amber-400" : undefined}>
|
||||
[{diag.level}] {diag.message}
|
||||
</p>
|
||||
{diag.hint && <p className="font-mono break-all">{diag.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -276,7 +296,7 @@ export function InviteLandingPage() {
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending
|
||||
? "Submitting..."
|
||||
? "Submitting…"
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: "Submit join request"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { ListTodo } from "lucide-react";
|
||||
|
||||
@@ -29,6 +30,10 @@ export function MyIssues() {
|
||||
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
// Show issues that are not assigned (user-created or unassigned)
|
||||
const myIssues = (issues ?? []).filter(
|
||||
(i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status)
|
||||
@@ -36,10 +41,9 @@ export function MyIssues() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{!isLoading && myIssues.length === 0 && (
|
||||
{myIssues.length === 0 && (
|
||||
<EmptyState icon={ListTodo} message="No issues assigned to you." />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ChevronRight, GitBranch } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -106,9 +107,12 @@ export function Org() {
|
||||
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{data && data.length === 0 && (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { agentUrl } from "../lib/utils";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { Network } from "lucide-react";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
@@ -254,7 +256,7 @@ export function OrgChart() {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground p-4">Loading...</p>;
|
||||
return <PageSkeleton variant="org-chart" />;
|
||||
}
|
||||
|
||||
if (orgTree && orgTree.length === 0) {
|
||||
@@ -287,6 +289,7 @@ export function OrgChart() {
|
||||
}
|
||||
setZoom(newZoom);
|
||||
}}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -303,6 +306,7 @@ export function OrgChart() {
|
||||
}
|
||||
setZoom(newZoom);
|
||||
}}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
@@ -321,6 +325,7 @@ export function OrgChart() {
|
||||
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
|
||||
}}
|
||||
title="Fit to screen"
|
||||
aria-label="Fit chart to screen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
@@ -371,14 +376,14 @@ export function OrgChart() {
|
||||
<div
|
||||
key={node.id}
|
||||
data-org-card
|
||||
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-all cursor-pointer select-none"
|
||||
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-[box-shadow,border-color] duration-150 cursor-pointer select-none"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: CARD_W,
|
||||
minHeight: CARD_H,
|
||||
}}
|
||||
onClick={() => navigate(`/agents/${node.id}`)}
|
||||
onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
|
||||
>
|
||||
<div className="flex items-center px-4 py-3 gap-3">
|
||||
{/* Agent icon + status dot */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "react-router-dom";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS } from "@paperclip/shared";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclip/shared";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -15,15 +15,20 @@ import { ProjectProperties } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectTab = "overview" | "list";
|
||||
|
||||
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
||||
const prefix = `/projects/${projectId}`;
|
||||
if (pathname === `${prefix}/overview`) return "overview";
|
||||
if (pathname.startsWith(`${prefix}/issues`)) return "list";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const projectsIdx = segments.indexOf("projects");
|
||||
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
|
||||
const tab = segments[projectsIdx + 2];
|
||||
if (tab === "overview") return "overview";
|
||||
if (tab === "issues") return "list";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ function ColorPicker({
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-all"
|
||||
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
aria-label="Change project color"
|
||||
/>
|
||||
@@ -109,7 +114,7 @@ function ColorPicker({
|
||||
onSelect(color);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`h-6 w-6 rounded-md cursor-pointer transition-all hover:scale-110 ${
|
||||
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
||||
color === currentColor
|
||||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
||||
: "hover:ring-2 hover:ring-foreground/30"
|
||||
@@ -127,20 +132,19 @@ function ColorPicker({
|
||||
|
||||
/* ── List (issues) tab content ── */
|
||||
|
||||
function ProjectIssuesList({ projectId }: { projectId: string }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
enabled: !!companyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
@@ -153,17 +157,17 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
|
||||
}, [liveRuns]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { projectId }),
|
||||
enabled: !!selectedCompanyId,
|
||||
queryKey: queryKeys.issues.listByProject(companyId, projectId),
|
||||
queryFn: () => issuesApi.list(companyId, { projectId }),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,47 +188,87 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
|
||||
/* ── Main project page ── */
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { companyPrefix, projectId, filter } = useParams<{
|
||||
companyPrefix?: string;
|
||||
projectId: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||||
}, [companies, companyPrefix]);
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||
|
||||
const activeTab = projectId ? resolveProjectTab(location.pathname, projectId) : null;
|
||||
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||||
|
||||
const { data: project, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.projects.detail(projectId!),
|
||||
queryFn: () => projectsApi.get(projectId!),
|
||||
enabled: !!projectId,
|
||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||||
enabled: canFetchProject,
|
||||
});
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
||||
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
const invalidateProject = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
|
||||
}
|
||||
};
|
||||
|
||||
const updateProject = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => projectsApi.update(projectId!, data),
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
|
||||
onSuccess: invalidateProject,
|
||||
});
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, `projects/${projectId ?? "draft"}`);
|
||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: project?.name ?? projectId ?? "Project" },
|
||||
{ label: project?.name ?? routeProjectRef ?? "Project" },
|
||||
]);
|
||||
}, [setBreadcrumbs, project, projectId]);
|
||||
}, [setBreadcrumbs, project, routeProjectRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
if (activeTab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${canonicalProjectRef}/issues`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${canonicalProjectRef}`, { replace: true });
|
||||
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
@@ -234,19 +278,19 @@ export function ProjectDetail() {
|
||||
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Redirect bare /projects/:id to /projects/:id/issues
|
||||
if (projectId && activeTab === null) {
|
||||
return <Navigate to={`/projects/${projectId}/issues`} replace />;
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!project) return null;
|
||||
|
||||
const handleTabChange = (tab: ProjectTab) => {
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${projectId}/overview`);
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else {
|
||||
navigate(`/projects/${projectId}/issues`);
|
||||
navigate(`/projects/${canonicalProjectRef}/issues`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,8 +347,8 @@ export function ProjectDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "list" && projectId && (
|
||||
<ProjectIssuesList projectId={projectId} />
|
||||
{activeTab === "list" && project?.id && resolvedCompanyId && (
|
||||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatDate, projectUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hexagon, Plus } from "lucide-react";
|
||||
|
||||
@@ -31,6 +32,10 @@ export function Projects() {
|
||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -40,7 +45,6 @@ export function Projects() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
@@ -59,7 +63,7 @@ export function Projects() {
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
to={`/projects/${project.id}`}
|
||||
to={projectUrl(project)}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{project.targetDate && (
|
||||
|
||||
Reference in New Issue
Block a user