Merge public-gh/master into review/pr-162
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -14,9 +14,9 @@ import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||
import { getUIAdapter, buildTranscript } from "../adapters";
|
||||
import type { TranscriptEntry } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
@@ -24,9 +24,11 @@ import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -52,11 +54,12 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
||||
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
@@ -90,11 +93,11 @@ function redactEnvValue(key: string, value: unknown): string {
|
||||
}
|
||||
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "string") return redactHomePathUserSegments(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
return redactHomePathUserSegments(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +175,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "overview" | "configure" | "runs";
|
||||
type AgentDetailView = "dashboard" | "configuration" | "runs";
|
||||
|
||||
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;
|
||||
return "overview";
|
||||
return "dashboard";
|
||||
}
|
||||
|
||||
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
||||
@@ -303,17 +306,23 @@ export function AgentDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
if (routeAgentRef === canonicalAgentRef) return;
|
||||
if (urlRunId) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
if (routeAgentRef !== canonicalAgentRef) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (urlTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true });
|
||||
const canonicalTab =
|
||||
activeView === "configuration"
|
||||
? "configuration"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/agents/${canonicalAgentRef}`, { replace: true });
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||
@@ -396,17 +405,19 @@ export function AgentDetail() {
|
||||
{ label: "Agents", href: "/agents" },
|
||||
];
|
||||
const agentName = agent?.name ?? routeAgentRef ?? "Agent";
|
||||
if (activeView === "overview" && !urlRunId) {
|
||||
if (activeView === "dashboard" && !urlRunId) {
|
||||
crumbs.push({ label: agentName });
|
||||
} else {
|
||||
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` });
|
||||
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` });
|
||||
if (urlRunId) {
|
||||
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||
} else if (activeView === "configure") {
|
||||
crumbs.push({ label: "Configure" });
|
||||
} else if (activeView === "configuration") {
|
||||
crumbs.push({ label: "Configuration" });
|
||||
} else if (activeView === "runs") {
|
||||
crumbs.push({ label: "Runs" });
|
||||
} else {
|
||||
crumbs.push({ label: "Dashboard" });
|
||||
}
|
||||
}
|
||||
setBreadcrumbs(crumbs);
|
||||
@@ -415,7 +426,7 @@ export function AgentDetail() {
|
||||
useEffect(() => {
|
||||
closePanel();
|
||||
return () => closePanel();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [closePanel]);
|
||||
|
||||
useBeforeUnload(
|
||||
useCallback((event) => {
|
||||
@@ -428,8 +439,11 @@ export function AgentDetail() {
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
if (!urlRunId && !urlTab) {
|
||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const showConfigActionBar = activeView === "configure" && configDirty;
|
||||
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||
@@ -498,7 +512,7 @@ export function AgentDetail() {
|
||||
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">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||
@@ -513,16 +527,6 @@ 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={() => {
|
||||
@@ -558,6 +562,23 @@ export function AgentDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!urlRunId && (
|
||||
<Tabs
|
||||
value={activeView}
|
||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||
>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "dashboard", label: "Dashboard" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
]}
|
||||
value={activeView}
|
||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<p className="text-sm text-amber-500">
|
||||
@@ -622,20 +643,18 @@ export function AgentDetail() {
|
||||
)}
|
||||
|
||||
{/* View content */}
|
||||
{activeView === "overview" && (
|
||||
{activeView === "dashboard" && (
|
||||
<AgentOverview
|
||||
agent={agent}
|
||||
runs={heartbeats ?? []}
|
||||
assignedIssues={assignedIssues}
|
||||
runtimeState={runtimeState}
|
||||
reportsToAgent={reportsToAgent ?? null}
|
||||
directReports={directReports}
|
||||
agentId={agent.id}
|
||||
agentRouteId={canonicalAgentRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "configure" && (
|
||||
{activeView === "configuration" && (
|
||||
<AgentConfigurePage
|
||||
agent={agent}
|
||||
agentId={agent.id}
|
||||
@@ -695,7 +714,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
)}
|
||||
@@ -749,8 +768,6 @@ function AgentOverview({
|
||||
runs,
|
||||
assignedIssues,
|
||||
runtimeState,
|
||||
reportsToAgent,
|
||||
directReports,
|
||||
agentId,
|
||||
agentRouteId,
|
||||
}: {
|
||||
@@ -758,8 +775,6 @@ function AgentOverview({
|
||||
runs: HeartbeatRun[];
|
||||
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
|
||||
runtimeState?: AgentRuntimeState;
|
||||
reportsToAgent: Agent | null;
|
||||
directReports: Agent[];
|
||||
agentId: string;
|
||||
agentRouteId: string;
|
||||
}) {
|
||||
@@ -819,131 +834,6 @@ function AgentOverview({
|
||||
<h3 className="text-sm font-medium">Costs</h3>
|
||||
<CostsSection runtimeState={runtimeState} runs={runs} />
|
||||
</div>
|
||||
|
||||
{/* Configuration Summary */}
|
||||
<ConfigSummary
|
||||
agent={agent}
|
||||
agentRouteId={agentRouteId}
|
||||
reportsToAgent={reportsToAgent}
|
||||
directReports={directReports}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Chart components imported from ../components/ActivityCharts */
|
||||
|
||||
/* ---- Configuration Summary ---- */
|
||||
|
||||
function ConfigSummary({
|
||||
agent,
|
||||
agentRouteId,
|
||||
reportsToAgent,
|
||||
directReports,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agentRouteId: string;
|
||||
reportsToAgent: Agent | null;
|
||||
directReports: Agent[];
|
||||
}) {
|
||||
const config = agent.adapterConfig as Record<string, unknown>;
|
||||
const promptText = typeof config?.promptTemplate === "string" ? config.promptTemplate : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<h4 className="text-xs text-muted-foreground font-medium">Agent Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<SummaryRow label="Adapter">
|
||||
<span className="font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
{String(config?.model ?? "") !== "" && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({String(config.model)})
|
||||
</span>
|
||||
)}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Heartbeat">
|
||||
{(agent.runtimeConfig as Record<string, unknown>)?.heartbeat
|
||||
? (() => {
|
||||
const hb = (agent.runtimeConfig as Record<string, unknown>).heartbeat as Record<string, unknown>;
|
||||
if (!hb.enabled) return <span className="text-muted-foreground">Disabled</span>;
|
||||
const sec = Number(hb.intervalSec) || 300;
|
||||
const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1));
|
||||
const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`;
|
||||
return (
|
||||
<span>
|
||||
Every {intervalLabel}
|
||||
{maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
: <span className="text-muted-foreground">Not configured</span>
|
||||
}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Last heartbeat">
|
||||
{agent.lastHeartbeatAt
|
||||
? <span>{relativeTime(agent.lastHeartbeatAt)}</span>
|
||||
: <span className="text-muted-foreground">Never</span>
|
||||
}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Reports to">
|
||||
{reportsToAgent ? (
|
||||
<Link
|
||||
to={`/agents/${agentRouteRef(reportsToAgent)}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Nobody (top-level)</span>
|
||||
)}
|
||||
</SummaryRow>
|
||||
</div>
|
||||
{directReports.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<span className="text-xs text-muted-foreground">Direct reports</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{directReports.map((r) => (
|
||||
<Link
|
||||
key={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">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
|
||||
</span>
|
||||
{r.name}
|
||||
<span className="text-muted-foreground text-xs">({roleLabels[r.role] ?? r.role})</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agent.capabilities && (
|
||||
<div className="pt-1">
|
||||
<span className="text-xs text-muted-foreground">Capabilities</span>
|
||||
<p className="text-sm mt-0.5">{agent.capabilities}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{promptText && (
|
||||
<div className="border border-border rounded-lg p-4 space-y-2">
|
||||
<h4 className="text-xs text-muted-foreground font-medium">Prompt Template</h4>
|
||||
<pre className="text-xs text-muted-foreground line-clamp-[12] font-mono whitespace-pre-wrap">{promptText}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -968,7 +858,7 @@ function CostsSection({
|
||||
<div className="space-y-4">
|
||||
{runtimeState && (
|
||||
<div className="border border-border rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 tabular-nums">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">Input tokens</span>
|
||||
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
|
||||
@@ -1007,9 +897,9 @@ function CostsSection({
|
||||
<tr key={run.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
|
||||
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{(u.cost_usd || u.total_cost_usd)
|
||||
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
|
||||
: "-"
|
||||
@@ -1154,6 +1044,8 @@ function ConfigurationTab({
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||
const lastAgentRef = useRef(agent);
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey:
|
||||
@@ -1166,16 +1058,31 @@ function ConfigurationTab({
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
onMutate: () => {
|
||||
setAwaitingRefreshAfterSave(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
},
|
||||
onError: () => {
|
||||
setAwaitingRefreshAfterSave(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange(updateAgent.isPending);
|
||||
}, [onSavingChange, updateAgent.isPending]);
|
||||
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||
setAwaitingRefreshAfterSave(false);
|
||||
}
|
||||
lastAgentRef.current = agent;
|
||||
}, [agent, awaitingRefreshAfterSave]);
|
||||
|
||||
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange(isConfigSaving);
|
||||
}, [onSavingChange, isConfigSaving]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -1183,7 +1090,7 @@ function ConfigurationTab({
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
onSave={(patch) => updateAgent.mutate(patch)}
|
||||
isSaving={updateAgent.isPending}
|
||||
isSaving={isConfigSaving}
|
||||
adapterModels={adapterModels}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveActionChange={onSaveActionChange}
|
||||
@@ -1257,7 +1164,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
</span>
|
||||
)}
|
||||
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
||||
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
||||
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
||||
</div>
|
||||
@@ -1348,9 +1255,15 @@ function RunsTab({
|
||||
|
||||
/* ---- Run Detail (expanded) ---- */
|
||||
|
||||
function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
||||
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { data: hydratedRun } = useQuery({
|
||||
queryKey: queryKeys.runDetail(initialRun.id),
|
||||
queryFn: () => heartbeatsApi.get(initialRun.id),
|
||||
enabled: Boolean(initialRun.id),
|
||||
});
|
||||
const run = hydratedRun ?? initialRun;
|
||||
const metrics = runMetrics(run);
|
||||
const [sessionOpen, setSessionOpen] = useState(false);
|
||||
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
|
||||
@@ -1627,7 +1540,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
|
||||
|
||||
{/* Right column: metrics */}
|
||||
{hasMetrics && (
|
||||
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center">
|
||||
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Input</div>
|
||||
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
|
||||
@@ -1747,6 +1660,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
|
||||
|
||||
{/* Log viewer */}
|
||||
<LogViewer run={run} adapterType={adapterType} />
|
||||
<ScrollToBottom />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1762,6 +1676,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
const [logOffset, setLogOffset] = useState(0);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
|
||||
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const pendingLogLineRef = useRef("");
|
||||
const scrollContainerRef = useRef<ScrollContainer | null>(null);
|
||||
@@ -2109,12 +2024,16 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
|
||||
const adapterInvokePayload = useMemo(() => {
|
||||
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
||||
return asRecord(evt?.payload ?? null);
|
||||
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
|
||||
}, [events]);
|
||||
|
||||
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTranscriptMode("nice");
|
||||
}, [run.id]);
|
||||
|
||||
if (loading && logLoading) {
|
||||
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
||||
}
|
||||
@@ -2178,8 +2097,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof adapterInvokePayload.prompt === "string"
|
||||
? adapterInvokePayload.prompt
|
||||
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
|
||||
? redactHomePathUserSegments(adapterInvokePayload.prompt)
|
||||
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -2187,7 +2106,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(adapterInvokePayload.context, null, 2)}
|
||||
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -2207,6 +2126,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
Transcript ({transcript.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-lg border border-border/70 bg-background/70 p-0.5">
|
||||
{(["nice", "raw"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors",
|
||||
transcriptMode === mode
|
||||
? "bg-accent text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTranscriptMode(mode)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isLive && !isFollowing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2225,7 +2161,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{isLive && (
|
||||
<span className="flex items-center gap-1 text-xs text-cyan-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
Live
|
||||
@@ -2233,123 +2169,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
||||
{transcript.length === 0 && !run.logRef && (
|
||||
<div className="text-neutral-500">No persisted transcript for this run.</div>
|
||||
<div className="max-h-[38rem] overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
|
||||
<RunTranscriptView
|
||||
entries={transcript}
|
||||
mode={transcriptMode}
|
||||
streaming={isLive}
|
||||
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
|
||||
/>
|
||||
{logError && (
|
||||
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
|
||||
{logError}
|
||||
</div>
|
||||
)}
|
||||
{transcript.map((entry, idx) => {
|
||||
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
|
||||
const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline";
|
||||
const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
||||
const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
|
||||
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden";
|
||||
const expandCell = "col-span-full md:col-start-3 md:col-span-1";
|
||||
|
||||
if (entry.kind === "assistant") {
|
||||
return (
|
||||
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
|
||||
<span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "thinking") {
|
||||
return (
|
||||
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
|
||||
<span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "user") {
|
||||
return (
|
||||
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
|
||||
<span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "tool_call") {
|
||||
return (
|
||||
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
|
||||
<span className="text-yellow-900 dark:text-yellow-100 min-w-0">{entry.name}</span>
|
||||
<pre className={cn(expandCell, "bg-neutral-200 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-800 dark:text-neutral-200")}>
|
||||
{JSON.stringify(entry.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "tool_result") {
|
||||
return (
|
||||
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, entry.isError ? "text-red-600 dark:text-red-300" : "text-purple-600 dark:text-purple-300")}>tool_result</span>
|
||||
{entry.isError ? <span className="text-red-600 dark:text-red-400 min-w-0">error</span> : <span />}
|
||||
<pre className={cn(expandCell, "bg-neutral-100 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-700 dark:text-neutral-300 max-h-60 overflow-y-auto")}>
|
||||
{(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "init") {
|
||||
return (
|
||||
<div key={`${entry.ts}-init-${idx}`} className={grid}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
|
||||
<span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "result") {
|
||||
return (
|
||||
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
|
||||
<span className={cn(contentCell, "text-cyan-900 dark:text-cyan-100")}>
|
||||
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
|
||||
</span>
|
||||
{(entry.subtype || entry.isError || entry.errors.length > 0) && (
|
||||
<div className={cn(expandCell, "text-red-600 dark:text-red-300 whitespace-pre-wrap break-words")}>
|
||||
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
|
||||
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{entry.text && (
|
||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-800 dark:text-neutral-100")}>{entry.text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rawText = entry.text;
|
||||
const label =
|
||||
entry.kind === "stderr" ? "stderr" :
|
||||
entry.kind === "system" ? "system" :
|
||||
"stdout";
|
||||
const color =
|
||||
entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
|
||||
entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
|
||||
"text-neutral-500";
|
||||
return (
|
||||
<div key={`${entry.ts}-raw-${idx}`} className={grid}>
|
||||
<span className={tsCell}>{time}</span>
|
||||
<span className={cn(lblCell, color)}>{label}</span>
|
||||
<span className={cn(contentCell, color)}>{rawText}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{logError && <div className="text-red-600 dark:text-red-300">{logError}</div>}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -2359,14 +2190,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{run.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-200">
|
||||
<span className="text-red-700 dark:text-red-300">Error: </span>
|
||||
{run.error}
|
||||
{redactHomePathUserSegments(run.error)}
|
||||
</div>
|
||||
)}
|
||||
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
|
||||
<div>
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stderrExcerpt}
|
||||
{redactHomePathUserSegments(run.stderrExcerpt)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -2374,7 +2205,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<div>
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{JSON.stringify(run.resultJson, null, 2)}
|
||||
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -2382,7 +2213,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<div>
|
||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
|
||||
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
|
||||
{run.stdoutExcerpt}
|
||||
{redactHomePathUserSegments(run.stdoutExcerpt)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -2408,7 +2239,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
{evt.stream ? `[${evt.stream}]` : ""}
|
||||
</span>
|
||||
<span className={cn("break-all", color)}>
|
||||
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
|
||||
{evt.message
|
||||
? redactHomePathUserSegments(evt.message)
|
||||
: evt.payload
|
||||
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,23 +18,20 @@ import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
||||
engineer: "Engineer", designer: "Designer", pm: "PM",
|
||||
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
||||
};
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
|
||||
@@ -230,7 +227,7 @@ export function Agents() {
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
leading={
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
@@ -402,7 +399,7 @@ function LiveRunIndicator({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
|
||||
@@ -244,7 +244,7 @@ export function Companies() {
|
||||
{issueCount} {issueCount === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 tabular-nums">
|
||||
<DollarSign className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{formatCents(company.spentMonthlyCents)}
|
||||
|
||||
@@ -81,9 +81,7 @@ export function CompanySettings() {
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: "agent"
|
||||
}),
|
||||
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||
onSuccess: async (invite) => {
|
||||
setInviteError(null);
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
@@ -403,9 +401,9 @@ export function CompanySettings() {
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Generate an agent snippet for join flows.
|
||||
Generate an OpenClaw agent invite snippet.
|
||||
</span>
|
||||
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
@@ -415,7 +413,7 @@ export function CompanySettings() {
|
||||
>
|
||||
{inviteMutation.isPending
|
||||
? "Generating..."
|
||||
: "Generate agent snippet"}
|
||||
: "Generate OpenClaw Invite Prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
{inviteError && (
|
||||
@@ -425,7 +423,7 @@ export function CompanySettings() {
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Agent Snippet
|
||||
OpenClaw Invite Prompt
|
||||
</div>
|
||||
{snippetCopied && (
|
||||
<span
|
||||
@@ -547,7 +545,7 @@ Suggested command:
|
||||
Then verify with: curl -fsS <base-url>/api/health`;
|
||||
|
||||
const resolutionLine = resolutionTestUrl
|
||||
? `\nYou MUST test callback reachability, call: ${resolutionTestUrl}?url=<urlencoded-webhook-url> (well using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
|
||||
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
|
||||
: "";
|
||||
|
||||
return `You're invited to join a Paperclip organization.
|
||||
@@ -562,13 +560,20 @@ Verify the hostname works from your runtime with: GET <base-url>/api/health
|
||||
${connectivityBlock}
|
||||
|
||||
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
|
||||
You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
|
||||
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
|
||||
|
||||
---
|
||||
|
||||
In order for Paperclip to work smoothly, you need to enable \`gateway.http.endpoints.responses.enabled=true\` in your OpenClaw config.
|
||||
This onboarding flow is for OpenClaw Gateway.
|
||||
When you submit the join request, use:
|
||||
- \`adapterType: "openclaw_gateway"\`
|
||||
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
|
||||
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
|
||||
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
|
||||
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
|
||||
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
|
||||
|
||||
Please enable that setting and notify your user that we did so.
|
||||
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
|
||||
|
||||
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export function Costs() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{formatCents(data.summary.spendCents)}{" "}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
{data.summary.budgetCents > 0
|
||||
@@ -192,7 +192,7 @@ export function Costs() {
|
||||
<StatusBadge status="terminated" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
<div className="text-right shrink-0 ml-2 tabular-nums">
|
||||
<span className="font-medium block">{formatCents(row.costCents)}</span>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
|
||||
@@ -229,7 +229,7 @@ export function Costs() {
|
||||
<span className="truncate">
|
||||
{row.projectName ?? row.projectId ?? "Unattributed"}
|
||||
</span>
|
||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
|
||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||
return [...issues]
|
||||
@@ -255,7 +256,7 @@ export function Dashboard() {
|
||||
to="/approvals"
|
||||
description={
|
||||
<span>
|
||||
{data.staleTasks} stale tasks
|
||||
Awaiting board review
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -276,6 +277,13 @@ export function Dashboard() {
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["dashboardWidget"]}
|
||||
context={{ companyId: selectedCompanyId }}
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Recent Activity */}
|
||||
{recentActivity.length > 0 && (
|
||||
@@ -313,26 +321,36 @@ export function Dashboard() {
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
|
||||
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate">
|
||||
<span>{issue.title}</span>
|
||||
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
|
||||
{/* Status icon - left column on mobile */}
|
||||
<span className="shrink-0 sm:hidden">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
|
||||
{/* Right column on mobile: title + metadata stacked */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
|
||||
{issue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
|
||||
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
|
||||
: null;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1061,7 +1061,7 @@ export function DesignGuide() {
|
||||
<div className="text-foreground">[12:00:17] INFO Reconnected successfully</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-ping" />
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-pulse" />
|
||||
<span className="inline-flex h-full w-full rounded-full bg-cyan-400" />
|
||||
</span>
|
||||
<span className="text-cyan-400">Live</span>
|
||||
@@ -1313,7 +1313,7 @@ export function DesignGuide() {
|
||||
["C", "New Issue (outside inputs)"],
|
||||
["[", "Toggle Sidebar"],
|
||||
["]", "Toggle Properties Panel"],
|
||||
["Cmd+1..9 / Ctrl+1..9", "Switch Company (by rail order)"],
|
||||
|
||||
["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between px-4 py-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
@@ -11,11 +11,13 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
X,
|
||||
@@ -40,59 +41,29 @@ import {
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
type InboxTab,
|
||||
saveLastInboxTab,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const RECENT_ISSUES_LIMIT = 100;
|
||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
type InboxTab = "new" | "all";
|
||||
type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts"
|
||||
| "stale_work";
|
||||
| "alerts";
|
||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
type SectionKey =
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts"
|
||||
| "stale_work";
|
||||
|
||||
const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
|
||||
function loadDismissed(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissed(ids: Set<string>) {
|
||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
||||
}
|
||||
|
||||
function useDismissedItems() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setDismissed((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
saveDismissed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { dismissed, dismiss };
|
||||
}
|
||||
| "alerts";
|
||||
|
||||
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||
timer: "Scheduled",
|
||||
@@ -101,32 +72,6 @@ const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
function getStaleIssues(issues: Issue[]): Issue[] {
|
||||
const now = Date.now();
|
||||
return issues
|
||||
.filter(
|
||||
(i) =>
|
||||
["in_progress", "todo"].includes(i.status) &&
|
||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
|
||||
)
|
||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
||||
}
|
||||
|
||||
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||
const sorted = [...runs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
const latestByAgent = new Map<string, HeartbeatRun>();
|
||||
|
||||
for (const run of sorted) {
|
||||
if (!latestByAgent.has(run.agentId)) {
|
||||
latestByAgent.set(run.agentId, run);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
@@ -137,23 +82,6 @@ function runFailureMessage(run: HeartbeatRun): string {
|
||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string | Date | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function issueLastActivityTimestamp(issue: Issue): number {
|
||||
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
|
||||
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
|
||||
|
||||
const updatedAt = normalizeTimestamp(issue.updatedAt);
|
||||
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
|
||||
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
|
||||
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
const context = run.contextSnapshot;
|
||||
if (!context) return null;
|
||||
@@ -171,11 +99,13 @@ function FailedRunCard({
|
||||
run,
|
||||
issueById,
|
||||
agentName: linkedAgentName,
|
||||
issueLinkState,
|
||||
onDismiss,
|
||||
}: {
|
||||
run: HeartbeatRun;
|
||||
issueById: Map<string, Issue>;
|
||||
agentName: string | null;
|
||||
issueLinkState: unknown;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -227,6 +157,7 @@ function FailedRunCard({
|
||||
{issue ? (
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
state={issueLinkState}
|
||||
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-1.5">
|
||||
@@ -311,10 +242,19 @@ export function Inbox() {
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const { dismissed, dismiss } = useDismissedItems();
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
||||
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "recent";
|
||||
const tab: InboxTab =
|
||||
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Inbox",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -326,6 +266,10 @@ export function Inbox() {
|
||||
setBreadcrumbs([{ label: "Inbox" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
saveLastInboxTab(tab);
|
||||
}, [tab]);
|
||||
|
||||
const {
|
||||
data: approvals,
|
||||
isLoading: isApprovalsLoading,
|
||||
@@ -385,22 +329,10 @@ export function Inbox() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const staleIssues = useMemo(
|
||||
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
|
||||
[issues, dismissed],
|
||||
);
|
||||
const sortByMostRecentActivity = useCallback(
|
||||
(a: Issue, b: Issue) => {
|
||||
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
|
||||
if (activityDiff !== 0) return activityDiff;
|
||||
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const touchedIssues = useMemo(
|
||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||
[sortByMostRecentActivity, touchedIssuesRaw],
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
@@ -419,6 +351,15 @@ export function Inbox() {
|
||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
||||
[heartbeatRuns, dismissed],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of heartbeatRuns ?? []) {
|
||||
if (run.status !== "running" && run.status !== "queued") continue;
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
if (issueId) ids.add(issueId);
|
||||
}
|
||||
return ids;
|
||||
}, [heartbeatRuns]);
|
||||
|
||||
const allApprovals = useMemo(
|
||||
() =>
|
||||
@@ -500,17 +441,20 @@ export function Inbox() {
|
||||
|
||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
};
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||
onMutate: (id) => {
|
||||
setFadingOutIssues((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
}
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onSettled: (_data, _error, id) => {
|
||||
setTimeout(() => {
|
||||
@@ -523,6 +467,31 @@ export function Inbox() {
|
||||
},
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: async (issueIds: string[]) => {
|
||||
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
|
||||
},
|
||||
onMutate: (issueIds) => {
|
||||
setFadingOutIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const issueId of issueIds) next.add(issueId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onSettled: (_data, _error, issueIds) => {
|
||||
setTimeout(() => {
|
||||
setFadingOutIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const issueId of issueIds) next.delete(issueId);
|
||||
return next;
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
@@ -535,16 +504,9 @@ export function Inbox() {
|
||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissed.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasJoinRequests = joinRequests.length > 0;
|
||||
const hasTouchedIssues = touchedIssues.length > 0;
|
||||
|
||||
const newItemCount =
|
||||
failedRuns.length +
|
||||
staleIssues.length +
|
||||
(showAggregateAgentError ? 1 : 0) +
|
||||
(showBudgetAlert ? 1 : 0);
|
||||
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showTouchedCategory =
|
||||
@@ -553,25 +515,26 @@ export function Inbox() {
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
||||
|
||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
||||
const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals;
|
||||
const showTouchedSection =
|
||||
tab === "all"
|
||||
? showTouchedCategory && hasTouchedIssues
|
||||
: tab === "unread"
|
||||
? unreadTouchedIssues.length > 0
|
||||
: hasTouchedIssues;
|
||||
const showJoinRequestsSection =
|
||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
||||
const showApprovalsSection =
|
||||
tab === "new"
|
||||
? actionableApprovals.length > 0
|
||||
: showApprovalsCategory && filteredAllApprovals.length > 0;
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||
const showApprovalsSection = tab === "all"
|
||||
? showApprovalsCategory && filteredAllApprovals.length > 0
|
||||
: actionableApprovals.length > 0;
|
||||
const showFailedRunsSection =
|
||||
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
||||
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
||||
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
|
||||
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
|
||||
|
||||
const visibleSections = [
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showStaleSection ? "stale_work" : null,
|
||||
showApprovalsSection ? "approvals" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showTouchedSection ? "issues_i_touched" : null,
|
||||
@@ -586,33 +549,44 @@ export function Inbox() {
|
||||
!isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
const unreadIssueIds = unreadTouchedIssues
|
||||
.filter((issue) => !fadingOutIssues.has(issue.id))
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "new",
|
||||
label: (
|
||||
<>
|
||||
New
|
||||
{newItemCount > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-500">
|
||||
{newItemCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{canMarkAllRead && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
@@ -627,7 +601,6 @@ export function Inbox() {
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
<SelectItem value="stale_work">Stale work</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -661,9 +634,11 @@ export function Inbox() {
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
message={
|
||||
tab === "new"
|
||||
? "No issues you're involved in yet."
|
||||
: "No inbox items match these filters."
|
||||
tab === "unread"
|
||||
? "No new inbox items."
|
||||
: tab === "recent"
|
||||
? "No recent inbox items."
|
||||
: "No inbox items match these filters."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -673,7 +648,7 @@ export function Inbox() {
|
||||
{showSeparatorBefore("approvals") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
|
||||
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{approvalsToRender.map((approval) => (
|
||||
@@ -764,6 +739,7 @@ export function Inbox() {
|
||||
run={run}
|
||||
issueById={issueById}
|
||||
agentName={agentName(run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismiss(`run:${run.id}`)}
|
||||
/>
|
||||
))}
|
||||
@@ -830,113 +806,56 @@ export function Inbox() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showStaleSection && (
|
||||
<>
|
||||
{showSeparatorBefore("stale_work") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Stale Work
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{staleIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="group/stale relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
|
||||
>
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
{issue.assigneeAgentId &&
|
||||
(() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? (
|
||||
<Identity name={name} size="sm" />
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
updated {timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(`stale:${issue.id}`)}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTouchedSection && (
|
||||
<>
|
||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
My Recent Issues
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{touchedIssues.map((issue) => {
|
||||
<div>
|
||||
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
return (
|
||||
<div
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="flex w-4 shrink-0 justify-center">
|
||||
{(isUnread || isFading) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markReadMutation.mutate(issue.id);
|
||||
}}
|
||||
className="group/dot flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
|
||||
isFading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex flex-1 min-w-0 cursor-pointer items-center gap-3 no-underline text-inherit"
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
</span>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
mobileMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
trailingMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
211
ui/src/pages/InstanceSettings.tsx
Normal file
211
ui/src/pages/InstanceSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock3, ExternalLink, Settings } from "lucide-react";
|
||||
import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime, relativeTime } from "../lib/utils";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function humanize(value: string) {
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function buildAgentHref(agent: InstanceSchedulerHeartbeatAgent) {
|
||||
return `/${agent.companyIssuePrefix}/agents/${encodeURIComponent(agent.agentUrlKey)}`;
|
||||
}
|
||||
|
||||
export function InstanceSettings() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Instance Settings" },
|
||||
{ label: "Heartbeats" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const heartbeatsQuery = useQuery({
|
||||
queryKey: queryKeys.instance.schedulerHeartbeats,
|
||||
queryFn: () => heartbeatsApi.listInstanceSchedulerAgents(),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (agentRow: InstanceSchedulerHeartbeatAgent) => {
|
||||
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
|
||||
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
|
||||
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
|
||||
|
||||
return agentsApi.update(
|
||||
agentRow.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...runtimeConfig,
|
||||
heartbeat: {
|
||||
...heartbeat,
|
||||
enabled: !agentRow.heartbeatEnabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentRow.companyId,
|
||||
);
|
||||
},
|
||||
onSuccess: async (_, agentRow) => {
|
||||
setActionError(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agentRow.companyId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRow.id) }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update heartbeat.");
|
||||
},
|
||||
});
|
||||
|
||||
const agents = heartbeatsQuery.data ?? [];
|
||||
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||
const disabledCount = agents.length - activeCount;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||
for (const agent of agents) {
|
||||
let group = map.get(agent.companyId);
|
||||
if (!group) {
|
||||
group = { companyName: agent.companyName, agents: [] };
|
||||
map.set(agent.companyId, group);
|
||||
}
|
||||
group.agents.push(agent);
|
||||
}
|
||||
return [...map.values()];
|
||||
}, [agents]);
|
||||
|
||||
if (heartbeatsQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading scheduler heartbeats...</div>;
|
||||
}
|
||||
|
||||
if (heartbeatsQuery.error) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
{heartbeatsQuery.error instanceof Error
|
||||
? heartbeatsQuery.error.message
|
||||
: "Failed to load scheduler heartbeats."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agents with a timer heartbeat enabled across all of your companies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
|
||||
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
|
||||
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
message="No scheduler heartbeats match the current criteria."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.companyName}>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.companyName}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{group.agents.map((agent) => {
|
||||
const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id;
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge
|
||||
variant={agent.schedulerActive ? "default" : "outline"}
|
||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{agent.schedulerActive ? "On" : "Off"}
|
||||
</Badge>
|
||||
<Link
|
||||
to={buildAgentHref(agent)}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{agent.agentName}
|
||||
</Link>
|
||||
<span className="hidden sm:inline text-muted-foreground truncate">
|
||||
{humanize(agent.title ?? agent.role)}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||
{agent.intervalSec}s
|
||||
</span>
|
||||
<span
|
||||
className="hidden md:inline text-muted-foreground truncate"
|
||||
title={agent.lastHeartbeatAt ? formatDateTime(agent.lastHeartbeatAt) : undefined}
|
||||
>
|
||||
{agent.lastHeartbeatAt
|
||||
? relativeTime(agent.lastHeartbeatAt)
|
||||
: "never"}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<Link
|
||||
to={buildAgentHref(agent)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Full agent config"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() => toggleMutation.mutate(agent)}
|
||||
>
|
||||
{saving ? "..." : agent.heartbeatEnabled ? "Disable Timer Heartbeat" : "Enable Timer Heartbeat"}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,22 +10,20 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [
|
||||
"openclaw",
|
||||
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
|
||||
];
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
gemini_local: "Gemini CLI (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { activityApi } from "../api/activity";
|
||||
@@ -8,21 +8,25 @@ import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -59,6 +63,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
@@ -96,6 +103,36 @@ function truncate(text: string, max: number): string {
|
||||
return text.slice(0, max - 1) + "\u2026";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
file.type === "text/markdown"
|
||||
);
|
||||
}
|
||||
|
||||
function fileBaseName(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function slugifyDocumentKey(input: string) {
|
||||
const slug = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "document";
|
||||
}
|
||||
|
||||
function titleizeFilename(input: string) {
|
||||
return input
|
||||
.split(/[-_ ]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
@@ -129,6 +166,14 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
|
||||
if (parts.length > 0) return parts.join(", ");
|
||||
}
|
||||
if (
|
||||
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||
details
|
||||
) {
|
||||
const key = typeof details.key === "string" ? details.key : "document";
|
||||
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
|
||||
}
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
@@ -146,11 +191,11 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { pushToast } = useToast();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
@@ -159,6 +204,7 @@ export function IssueDetail() {
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -167,6 +213,7 @@ export function IssueDetail() {
|
||||
queryFn: () => issuesApi.get(issueId!),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.issues.comments(issueId!),
|
||||
@@ -214,6 +261,10 @@ export function IssueDetail() {
|
||||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state],
|
||||
);
|
||||
|
||||
// Filter out runs already shown by the live widget to avoid duplication
|
||||
const timelineRuns = useMemo(() => {
|
||||
@@ -252,6 +303,21 @@ export function IssueDetail() {
|
||||
companyId: selectedCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
const { slots: issuePluginDetailSlots } = usePluginSlots({
|
||||
slotTypes: ["detailTab"],
|
||||
entityType: "issue",
|
||||
companyId: resolvedCompanyId,
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
const issuePluginTabItems = useMemo(
|
||||
() => issuePluginDetailSlots.map((slot) => ({
|
||||
value: `plugin:${slot.pluginKey}:${slot.id}`,
|
||||
label: slot.displayName,
|
||||
slot,
|
||||
})),
|
||||
[issuePluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null;
|
||||
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, Agent>();
|
||||
@@ -299,8 +365,7 @@ export function IssueDetail() {
|
||||
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
||||
}
|
||||
if (currentUserId) {
|
||||
const label = currentUserId === "local-board" ? "Board" : "Me (Board)";
|
||||
options.push({ id: `user:${currentUserId}`, label });
|
||||
options.push({ id: `user:${currentUserId}`, label: "Me" });
|
||||
}
|
||||
return options;
|
||||
}, [agents, currentUserId]);
|
||||
@@ -380,6 +445,7 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
@@ -403,33 +469,17 @@ export function IssueDetail() {
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||
onSuccess: (updated) => {
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`;
|
||||
pushToast({
|
||||
dedupeKey: `activity:issue.updated:${updated.id}`,
|
||||
title: `${issueRef} updated`,
|
||||
body: truncate(updated.title, 96),
|
||||
tone: "success",
|
||||
action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen),
|
||||
onSuccess: (comment) => {
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
||||
pushToast({
|
||||
dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`,
|
||||
title: `Comment posted on ${issueRef}`,
|
||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -449,17 +499,9 @@ export function IssueDetail() {
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
||||
pushToast({
|
||||
dedupeKey: `activity:issue.reassigned:${updated.id}`,
|
||||
title: `${issueRef} reassigned`,
|
||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -478,6 +520,30 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMarkdownDocument = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const key = slugifyDocumentKey(baseName);
|
||||
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
|
||||
const body = await file.text();
|
||||
const inferredTitle = titleizeFilename(baseName);
|
||||
const nextTitle = existing?.title ?? inferredTitle ?? null;
|
||||
return issuesApi.upsertDocument(issueId!, key, {
|
||||
title: key === "plan" ? null : nextTitle,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setAttachmentError(null);
|
||||
invalidateIssue();
|
||||
},
|
||||
onError: (err) => {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAttachment = useMutation({
|
||||
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
||||
onSuccess: () => {
|
||||
@@ -493,17 +559,17 @@ export function IssueDetail() {
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
setBreadcrumbs([
|
||||
{ label: "Issues", href: "/issues" },
|
||||
sourceBreadcrumb,
|
||||
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
|
||||
]);
|
||||
}, [setBreadcrumbs, issue, issueId, hasLiveRuns]);
|
||||
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
|
||||
|
||||
// Redirect to identifier-based URL if navigated via UUID
|
||||
useEffect(() => {
|
||||
if (issue?.identifier && issueId !== issue.identifier) {
|
||||
navigate(`/issues/${issue.identifier}`, { replace: true });
|
||||
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
|
||||
}
|
||||
}, [issue, issueId, navigate]);
|
||||
}, [issue, issueId, navigate, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
@@ -529,15 +595,62 @@ export function IssueDetail() {
|
||||
const ancestors = issue.ancestors ?? [];
|
||||
|
||||
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
const files = evt.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(false);
|
||||
const files = evt.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||
const attachmentList = attachments ?? [];
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||
className={cn(
|
||||
"shadow-none",
|
||||
attachmentDragActive && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
@@ -549,6 +662,7 @@ export function IssueDetail() {
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||
state={location.state}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
>
|
||||
@@ -583,7 +697,7 @@ export function IssueDetail() {
|
||||
{hasLiveRuns && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
||||
</span>
|
||||
Live
|
||||
@@ -677,16 +791,16 @@ export function IssueDetail() {
|
||||
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutate({ title })}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutate({ description })}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
as="p"
|
||||
className="text-sm text-muted-foreground"
|
||||
className="text-[15px] leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
multiline
|
||||
mentions={mentionOptions}
|
||||
@@ -697,77 +811,127 @@ export function IssueDetail() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="issue"
|
||||
context={{
|
||||
companyId: issue.companyId,
|
||||
projectId: issue.projectId ?? null,
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
|
||||
<PluginLauncherOutlet
|
||||
placementZones={["toolbarButton"]}
|
||||
entityType="issue"
|
||||
context={{
|
||||
companyId: issue.companyId,
|
||||
projectId: issue.projectId ?? null,
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
/>
|
||||
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["taskDetailView"]}
|
||||
entityType="issue"
|
||||
context={{
|
||||
companyId: issue.companyId,
|
||||
projectId: issue.projectId ?? null,
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="space-y-3"
|
||||
itemClassName="rounded-lg border border-border p-3"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||
mentions={mentionOptions}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
||||
/>
|
||||
|
||||
{hasAttachments ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg transition-colors",
|
||||
)}
|
||||
onDragEnter={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragLeave={(evt) => {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setAttachmentDragActive(false);
|
||||
}}
|
||||
onDrop={(evt) => void handleAttachmentDrop(evt)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
||||
</Button>
|
||||
</div>
|
||||
{attachmentUploadButton}
|
||||
</div>
|
||||
|
||||
{attachmentError && (
|
||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||
)}
|
||||
|
||||
{(!attachments || attachments.length === 0) ? (
|
||||
<p className="text-xs text-muted-foreground">No attachments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{attachmentList.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -785,12 +949,19 @@ export function IssueDetail() {
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
{issuePluginTabItems.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
linkedRuns={timelineRuns}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
@@ -825,6 +996,7 @@ export function IssueDetail() {
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
state={location.state}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -862,6 +1034,21 @@ export function IssueDetail() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activePluginTab && (
|
||||
<TabsContent value={activePluginTab.value}>
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
companyId: issue.companyId,
|
||||
projectId: issue.projectId ?? null,
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
@@ -918,7 +1105,7 @@ export function IssueDetail() {
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
|
||||
{issueCostSummary.hasCost && (
|
||||
<span className="font-medium text-foreground">
|
||||
${issueCostSummary.cost.toFixed(4)}
|
||||
@@ -952,6 +1139,7 @@ export function IssueDetail() {
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<ScrollToBottom />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import { useLocation, useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { CircleDot } from "lucide-react";
|
||||
@@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react";
|
||||
export function Issues() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -63,6 +65,15 @@ export function Issues() {
|
||||
return ids;
|
||||
}, [liveRuns]);
|
||||
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
@@ -93,6 +104,7 @@ export function Issues() {
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
viewStateKey="paperclip:issues-view"
|
||||
issueLinkState={issueLinkState}
|
||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
|
||||
338
ui/src/pages/NewAgent.tsx
Normal file
338
ui/src/pages/NewAgent.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AGENT_ROLES } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Shield, User } from "lucide-react";
|
||||
import { cn, agentUrl } from "../lib/utils";
|
||||
import { roleLabels } from "../components/agent-config-primitives";
|
||||
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
|
||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
function createValuesForAdapterType(
|
||||
adapterType: CreateConfigValues["adapterType"],
|
||||
): CreateConfigValues {
|
||||
const { adapterType: _discard, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType };
|
||||
if (adapterType === "codex_local") {
|
||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (adapterType === "gemini_local") {
|
||||
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
} else if (adapterType === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
export function NewAgent() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const presetAdapterType = searchParams.get("adapterType");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [role, setRole] = useState("general");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [reportsToOpen, setReportsToOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
|
||||
: ["agents", "none", "adapter-models", configValues.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Agents", href: "/agents" },
|
||||
{ label: "New Agent" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstAgent) {
|
||||
if (!name) setName("CEO");
|
||||
if (!title) setTitle("CEO");
|
||||
}
|
||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const requested = presetAdapterType;
|
||||
if (!requested) return;
|
||||
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
|
||||
return;
|
||||
}
|
||||
setConfigValues((prev) => {
|
||||
if (prev.adapterType === requested) return prev;
|
||||
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||
});
|
||||
}, [presetAdapterType]);
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(agentUrl(result.agent));
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||
},
|
||||
});
|
||||
|
||||
function buildAdapterConfig() {
|
||||
const adapter = getUIAdapter(configValues.adapterType);
|
||||
return adapter.buildAdapterConfig(configValues);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
setFormError(null);
|
||||
if (configValues.adapterType === "opencode_local") {
|
||||
const selectedModel = configValues.model.trim();
|
||||
if (!selectedModel) {
|
||||
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||
return;
|
||||
}
|
||||
if (adapterModelsError) {
|
||||
setFormError(
|
||||
adapterModelsError instanceof Error
|
||||
? adapterModelsError.message
|
||||
: "Failed to load OpenCode models.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setFormError("OpenCode models are still loading. Please wait and try again.");
|
||||
return;
|
||||
}
|
||||
const discovered = adapterModels ?? [];
|
||||
if (!discovered.some((entry) => entry.id === selectedModel)) {
|
||||
setFormError(
|
||||
discovered.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModel}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
createAgent.mutate({
|
||||
name: name.trim(),
|
||||
role: effectiveRole,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(reportsTo ? { reportsTo } : {}),
|
||||
adapterType: configValues.adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: configValues.heartbeatEnabled,
|
||||
intervalSec: configValues.intervalSec,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
budgetMonthlyCents: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">New Agent</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Advanced agent configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-border">
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<input
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Agent name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pb-2">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
||||
placeholder="Title (e.g. VP of Engineering)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property chips: Role + Reports To */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
||||
isFirstAgent && "opacity-60 cursor-not-allowed"
|
||||
)}
|
||||
disabled={isFirstAgent}
|
||||
>
|
||||
<Shield className="h-3 w-3 text-muted-foreground" />
|
||||
{roleLabels[effectiveRole] ?? effectiveRole}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{AGENT_ROLES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
r === role && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setRole(r); setRoleOpen(false); }}
|
||||
>
|
||||
{roleLabels[r] ?? r}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
||||
isFirstAgent && "opacity-60 cursor-not-allowed"
|
||||
)}
|
||||
disabled={isFirstAgent}
|
||||
>
|
||||
{currentReportsTo ? (
|
||||
<>
|
||||
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
|
||||
{`Reports to ${currentReportsTo.name}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!reportsTo && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
|
||||
>
|
||||
No manager
|
||||
</button>
|
||||
{(agents ?? []).map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
||||
a.id === reportsTo && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Shared config form */}
|
||||
<AgentConfigForm
|
||||
mode="create"
|
||||
values={configValues}
|
||||
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
||||
adapterModels={adapterModels}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{isFirstAgent && (
|
||||
<p className="text-xs text-muted-foreground mb-2">This will be the CEO</p>
|
||||
)}
|
||||
{formError && (
|
||||
<p className="text-xs text-destructive mb-2">{formError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createAgent.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createAgent.isPending ? "Creating…" : "Create agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
ui/src/pages/NotFound.tsx
Normal file
66
ui/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { AlertTriangle, Compass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
||||
type NotFoundScope = "board" | "invalid_company_prefix" | "global";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
scope?: NotFoundScope;
|
||||
requestedPrefix?: string;
|
||||
}
|
||||
|
||||
export function NotFoundPage({ scope = "global", requestedPrefix }: NotFoundPageProps) {
|
||||
const location = useLocation();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { companies, selectedCompany } = useCompany();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Not Found" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const fallbackCompany = selectedCompany ?? companies[0] ?? null;
|
||||
const dashboardHref = fallbackCompany ? `/${fallbackCompany.issuePrefix}/dashboard` : "/";
|
||||
const currentPath = `${location.pathname}${location.search}${location.hash}`;
|
||||
const normalizedPrefix = requestedPrefix?.toUpperCase();
|
||||
|
||||
const title = scope === "invalid_company_prefix" ? "Company not found" : "Page not found";
|
||||
const description =
|
||||
scope === "invalid_company_prefix"
|
||||
? `No company matches prefix "${normalizedPrefix ?? "unknown"}".`
|
||||
: "This route does not exist.";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Requested path: <code className="font-mono">{currentPath}</code>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={dashboardHref}>
|
||||
<Compass className="mr-1.5 h-4 w-4" />
|
||||
Open dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Go home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { Network } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
// Layout constants
|
||||
const CARD_W = 200;
|
||||
@@ -118,9 +118,10 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
@@ -268,7 +269,7 @@ export function OrgChart() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-[calc(100vh-4rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -421,11 +422,7 @@ export function OrgChart() {
|
||||
);
|
||||
}
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
||||
engineer: "Engineer", designer: "Designer", pm: "PM",
|
||||
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
||||
};
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return roleLabels[role] ?? role;
|
||||
|
||||
509
ui/src/pages/PluginManager.tsx
Normal file
509
ui/src/pages/PluginManager.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* @fileoverview Plugin Manager page — admin UI for discovering,
|
||||
* installing, enabling/disabling, and uninstalling plugins.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find(Boolean);
|
||||
return line ?? null;
|
||||
}
|
||||
|
||||
function getPluginErrorSummary(plugin: PluginRecord): string {
|
||||
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
|
||||
}
|
||||
|
||||
/**
|
||||
* PluginManager page component.
|
||||
*
|
||||
* Provides a management UI for the Paperclip plugin system:
|
||||
* - Lists all installed plugins with their status, version, and category badges.
|
||||
* - Allows installing new plugins by npm package name.
|
||||
* - Provides per-plugin actions: enable, disable, navigate to settings.
|
||||
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
|
||||
*
|
||||
* Data flow:
|
||||
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
|
||||
* - Mutations (install / uninstall / enable / disable) invalidate
|
||||
* `queryKeys.plugins.all` so the list refreshes automatically.
|
||||
*
|
||||
* @see PluginSettings — linked from the Settings icon on each plugin row.
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
|
||||
*/
|
||||
export function PluginManager() {
|
||||
const { selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const [installPackage, setInstallPackage] = useState("");
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
|
||||
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
|
||||
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/instance/settings/heartbeats" },
|
||||
{ label: "Plugins" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const { data: plugins, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.plugins.all,
|
||||
queryFn: () => pluginsApi.list(),
|
||||
});
|
||||
|
||||
const examplesQuery = useQuery({
|
||||
queryKey: queryKeys.plugins.examples,
|
||||
queryFn: () => pluginsApi.listExamples(),
|
||||
});
|
||||
|
||||
const invalidatePluginQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions });
|
||||
};
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
|
||||
pluginsApi.install(params),
|
||||
onSuccess: () => {
|
||||
invalidatePluginQueries();
|
||||
setInstallDialogOpen(false);
|
||||
setInstallPackage("");
|
||||
pushToast({ title: "Plugin installed successfully", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
|
||||
onSuccess: () => {
|
||||
invalidatePluginQueries();
|
||||
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
|
||||
onSuccess: () => {
|
||||
invalidatePluginQueries();
|
||||
pushToast({ title: "Plugin enabled", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
|
||||
onSuccess: () => {
|
||||
invalidatePluginQueries();
|
||||
pushToast({ title: "Plugin disabled", tone: "info" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const installedPlugins = plugins ?? [];
|
||||
const examples = examplesQuery.data ?? [];
|
||||
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
|
||||
const examplePackageNames = new Set(examples.map((example) => example.packageName));
|
||||
const errorSummaryByPluginId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
|
||||
),
|
||||
[installedPlugins]
|
||||
);
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
|
||||
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">Plugin Manager</h1>
|
||||
</div>
|
||||
|
||||
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Install Plugin
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install Plugin</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the npm package name of the plugin you wish to install.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="packageName">npm Package Name</Label>
|
||||
<Input
|
||||
id="packageName"
|
||||
placeholder="@paperclipai/plugin-example"
|
||||
value={installPackage}
|
||||
onChange={(e) => setInstallPackage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => installMutation.mutate({ packageName: installPackage })}
|
||||
disabled={!installPackage || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">Plugins are alpha.</p>
|
||||
<p className="text-muted-foreground">
|
||||
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Available Plugins</h2>
|
||||
<Badge variant="outline">Examples</Badge>
|
||||
</div>
|
||||
|
||||
{examplesQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
|
||||
) : examplesQuery.error ? (
|
||||
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
|
||||
) : examples.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
No bundled example plugins were found in this checkout.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{examples.map((example) => {
|
||||
const installedPlugin = installedByPackageName.get(example.packageName);
|
||||
const installPending =
|
||||
installMutation.isPending &&
|
||||
installMutation.variables?.isLocalPath &&
|
||||
installMutation.variables.packageName === example.localPath;
|
||||
|
||||
return (
|
||||
<li key={example.packageName}>
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{example.displayName}</span>
|
||||
<Badge variant="outline">Example</Badge>
|
||||
{installedPlugin ? (
|
||||
<Badge
|
||||
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
|
||||
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{installedPlugin.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not installed</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{installedPlugin ? (
|
||||
<>
|
||||
{installedPlugin.status !== "ready" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={enableMutation.isPending}
|
||||
onClick={() => enableMutation.mutate(installedPlugin.id)}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/instance/settings/plugins/${installedPlugin.id}`}>
|
||||
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={installPending || installMutation.isPending}
|
||||
onClick={() =>
|
||||
installMutation.mutate({
|
||||
packageName: example.localPath,
|
||||
isLocalPath: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{installPending ? "Installing..." : "Install Example"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Installed Plugins</h2>
|
||||
</div>
|
||||
|
||||
{!installedPlugins.length ? (
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm font-medium">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Install a plugin to extend functionality.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{installedPlugins.map((plugin) => (
|
||||
<li key={plugin.id}>
|
||||
<div className="flex items-start gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/instance/settings/plugins/${plugin.id}`}
|
||||
className="font-medium hover:underline truncate block"
|
||||
title={plugin.manifestJson.displayName ?? plugin.packageName}
|
||||
>
|
||||
{plugin.manifestJson.displayName ?? plugin.packageName}
|
||||
</Link>
|
||||
{examplePackageNames.has(plugin.packageName) && (
|
||||
<Badge variant="outline">Example</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
|
||||
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
|
||||
{plugin.manifestJson.description || "No description provided."}
|
||||
</p>
|
||||
{plugin.status === "error" && (
|
||||
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>Plugin error</span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
|
||||
title={plugin.lastError ?? undefined}
|
||||
>
|
||||
{errorSummaryByPluginId.get(plugin.id)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||
onClick={() => setErrorDetailsPlugin(plugin)}
|
||||
>
|
||||
View full error
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 self-center">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
plugin.status === "ready"
|
||||
? "default"
|
||||
: plugin.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
|
||||
)}
|
||||
>
|
||||
{plugin.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8"
|
||||
title={plugin.status === "ready" ? "Disable" : "Enable"}
|
||||
onClick={() => {
|
||||
if (plugin.status === "ready") {
|
||||
disableMutation.mutate(plugin.id);
|
||||
} else {
|
||||
enableMutation.mutate(plugin.id);
|
||||
}
|
||||
}}
|
||||
disabled={enableMutation.isPending || disableMutation.isPending}
|
||||
>
|
||||
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="Uninstall"
|
||||
onClick={() => {
|
||||
setUninstallPluginId(plugin.id);
|
||||
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
|
||||
}}
|
||||
disabled={uninstallMutation.isPending}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
|
||||
<Link to={`/instance/settings/plugins/${plugin.id}`}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configure
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={uninstallPluginId !== null}
|
||||
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uninstall Plugin</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={uninstallMutation.isPending}
|
||||
onClick={() => {
|
||||
if (uninstallPluginId) {
|
||||
uninstallMutation.mutate(uninstallPluginId, {
|
||||
onSettled: () => setUninstallPluginId(null),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={errorDetailsPlugin !== null}
|
||||
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Error Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-red-700 dark:text-red-300">
|
||||
What errored
|
||||
</p>
|
||||
<p className="text-red-700/90 dark:text-red-200/90 break-words">
|
||||
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Full error output</p>
|
||||
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{errorDetailsPlugin?.lastError ?? "No stored error message."}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
ui/src/pages/PluginPage.tsx
Normal file
156
ui/src/pages/PluginPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PluginSlotMount } from "@/plugins/slots";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { NotFoundPage } from "./NotFound";
|
||||
|
||||
/**
|
||||
* Company-context plugin page. Renders a plugin's `page` slot at
|
||||
* `/:companyPrefix/plugins/:pluginId` when the plugin declares a page slot
|
||||
* and is enabled for that company.
|
||||
*
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||
*/
|
||||
export function PluginPage() {
|
||||
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
|
||||
companyPrefix?: string;
|
||||
pluginId?: string;
|
||||
pluginRoutePath?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const routeCompany = useMemo(() => {
|
||||
if (!routeCompanyPrefix) return null;
|
||||
const requested = routeCompanyPrefix.toUpperCase();
|
||||
return companies.find((c) => c.issuePrefix.toUpperCase() === requested) ?? null;
|
||||
}, [companies, routeCompanyPrefix]);
|
||||
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
|
||||
|
||||
const resolvedCompanyId = useMemo(() => {
|
||||
if (routeCompany) return routeCompany.id;
|
||||
if (routeCompanyPrefix) return null;
|
||||
return selectedCompanyId ?? null;
|
||||
}, [routeCompany, routeCompanyPrefix, selectedCompanyId]);
|
||||
|
||||
const companyPrefix = useMemo(
|
||||
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
|
||||
[companies, resolvedCompanyId],
|
||||
);
|
||||
|
||||
const { data: contributions } = useQuery({
|
||||
queryKey: queryKeys.plugins.uiContributions,
|
||||
queryFn: () => pluginsApi.listUiContributions(),
|
||||
enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath),
|
||||
});
|
||||
|
||||
const pageSlot = useMemo(() => {
|
||||
if (!contributions) return null;
|
||||
if (pluginId) {
|
||||
const contribution = contributions.find((c) => c.pluginId === pluginId);
|
||||
if (!contribution) return null;
|
||||
const slot = contribution.slots.find((s) => s.type === "page");
|
||||
if (!slot) return null;
|
||||
return {
|
||||
...slot,
|
||||
pluginId: contribution.pluginId,
|
||||
pluginKey: contribution.pluginKey,
|
||||
pluginDisplayName: contribution.displayName,
|
||||
pluginVersion: contribution.version,
|
||||
};
|
||||
}
|
||||
if (!pluginRoutePath) return null;
|
||||
const matches = contributions.flatMap((contribution) => {
|
||||
const slot = contribution.slots.find((entry) => entry.type === "page" && entry.routePath === pluginRoutePath);
|
||||
if (!slot) return [];
|
||||
return [{
|
||||
...slot,
|
||||
pluginId: contribution.pluginId,
|
||||
pluginKey: contribution.pluginKey,
|
||||
pluginDisplayName: contribution.displayName,
|
||||
pluginVersion: contribution.version,
|
||||
}];
|
||||
});
|
||||
if (matches.length !== 1) return null;
|
||||
return matches[0] ?? null;
|
||||
}, [pluginId, pluginRoutePath, contributions]);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
companyId: resolvedCompanyId ?? null,
|
||||
companyPrefix,
|
||||
}),
|
||||
[resolvedCompanyId, companyPrefix],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageSlot) {
|
||||
setBreadcrumbs([
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: pageSlot.pluginDisplayName },
|
||||
]);
|
||||
}
|
||||
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
||||
|
||||
if (!resolvedCompanyId) {
|
||||
if (hasInvalidCompanyPrefix) {
|
||||
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contributions) {
|
||||
return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (!pluginId && pluginRoutePath) {
|
||||
const duplicateMatches = contributions.filter((contribution) =>
|
||||
contribution.slots.some((slot) => slot.type === "page" && slot.routePath === pluginRoutePath),
|
||||
);
|
||||
if (duplicateMatches.length > 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
Multiple plugins declare the route <code>{pluginRoutePath}</code>. Use the plugin-id route until the conflict is resolved.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageSlot) {
|
||||
if (pluginRoutePath) {
|
||||
return <NotFoundPage scope="board" />;
|
||||
}
|
||||
// No page slot: redirect to plugin settings where plugin info is always shown
|
||||
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
|
||||
return <Navigate to={settingsPath} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={context}
|
||||
className="min-h-[200px]"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
836
ui/src/pages/PluginSettings.tsx
Normal file
836
ui/src/pages/PluginSettings.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
validateJsonSchemaForm,
|
||||
getDefaultValues,
|
||||
type JsonSchemaNode,
|
||||
} from "@/components/JsonSchemaForm";
|
||||
|
||||
/**
|
||||
* PluginSettings page component.
|
||||
*
|
||||
* Detailed settings and diagnostics page for a single installed plugin.
|
||||
* Navigated to from {@link PluginManager} via the Settings gear icon.
|
||||
*
|
||||
* Displays:
|
||||
* - Plugin identity: display name, id, version, description, categories.
|
||||
* - Manifest-declared capabilities (what data and features the plugin can access).
|
||||
* - Health check results (only for `ready` plugins; polled every 30 seconds).
|
||||
* - Runtime dashboard: worker status/uptime, recent job runs, webhook deliveries.
|
||||
* - Auto-generated config form from `instanceConfigSchema` (when no custom settings page).
|
||||
* - Plugin-contributed settings UI via `<PluginSlotOutlet type="settingsPage" />`.
|
||||
*
|
||||
* Data flow:
|
||||
* - `GET /api/plugins/:pluginId` — plugin record (refreshes on mount).
|
||||
* - `GET /api/plugins/:pluginId/health` — health diagnostics (polling).
|
||||
* Only fetched when `plugin.status === "ready"`.
|
||||
* - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling).
|
||||
* - `GET /api/plugins/:pluginId/config` — current config values.
|
||||
* - `POST /api/plugins/:pluginId/config` — save config values.
|
||||
* - `POST /api/plugins/:pluginId/config/test` — test configuration.
|
||||
*
|
||||
* URL params:
|
||||
* - `companyPrefix` — the company slug (for breadcrumb links).
|
||||
* - `pluginId` — UUID of the plugin to display.
|
||||
*
|
||||
* @see PluginManager — parent list page.
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §13 — Plugin Health Checks.
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
|
||||
*/
|
||||
export function PluginSettings() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
||||
|
||||
const { data: plugin, isLoading: pluginLoading } = useQuery({
|
||||
queryKey: queryKeys.plugins.detail(pluginId!),
|
||||
queryFn: () => pluginsApi.get(pluginId!),
|
||||
enabled: !!pluginId,
|
||||
});
|
||||
|
||||
const { data: healthData, isLoading: healthLoading } = useQuery({
|
||||
queryKey: queryKeys.plugins.health(pluginId!),
|
||||
queryFn: () => pluginsApi.health(pluginId!),
|
||||
enabled: !!pluginId && plugin?.status === "ready",
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: dashboardData } = useQuery({
|
||||
queryKey: queryKeys.plugins.dashboard(pluginId!),
|
||||
queryFn: () => pluginsApi.dashboard(pluginId!),
|
||||
enabled: !!pluginId,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: recentLogs } = useQuery({
|
||||
queryKey: queryKeys.plugins.logs(pluginId!),
|
||||
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
|
||||
enabled: !!pluginId && plugin?.status === "ready",
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Fetch existing config for the plugin
|
||||
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
|
||||
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
||||
|
||||
const { data: configData, isLoading: configLoading } = useQuery({
|
||||
queryKey: queryKeys.plugins.config(pluginId!),
|
||||
queryFn: () => pluginsApi.getConfig(pluginId!),
|
||||
enabled: !!pluginId && !!hasConfigSchema,
|
||||
});
|
||||
|
||||
const { slots } = usePluginSlots({
|
||||
slotTypes: ["settingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Filter slots to only show settings pages for this specific plugin
|
||||
const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId);
|
||||
|
||||
// If the plugin has a custom settingsPage slot, prefer that over auto-generated form
|
||||
const hasCustomSettingsPage = pluginSlots.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/instance/settings/heartbeats" },
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab("configuration");
|
||||
}, [pluginId]);
|
||||
|
||||
if (pluginLoading) {
|
||||
return <div className="p-4 text-sm text-muted-foreground">Loading plugin details...</div>;
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return <Navigate to="/instance/settings/plugins" replace />;
|
||||
}
|
||||
|
||||
const displayStatus = plugin.status;
|
||||
const statusVariant =
|
||||
plugin.status === "ready"
|
||||
? "default"
|
||||
: plugin.status === "error"
|
||||
? "destructive"
|
||||
: "secondary";
|
||||
const pluginDescription = plugin.manifestJson.description || "No description provided.";
|
||||
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/instance/settings/plugins">
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">{plugin.manifestJson.displayName ?? plugin.packageName}</h1>
|
||||
<Badge variant={statusVariant} className="ml-2">
|
||||
{displayStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="ml-1">
|
||||
v{plugin.manifestJson.version ?? plugin.version}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "configuration" | "status")} className="space-y-6">
|
||||
<PageTabBar
|
||||
align="start"
|
||||
items={[
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "status", label: "Status" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "configuration" | "status")}
|
||||
/>
|
||||
|
||||
<TabsContent value="configuration" className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-5">
|
||||
<h2 className="text-base font-semibold">About</h2>
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.8fr)]">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
|
||||
<p className="text-sm leading-6 text-foreground/90">{pluginDescription}</p>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="font-medium text-muted-foreground">Author</h3>
|
||||
<p className="text-foreground">{plugin.manifestJson.author}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-muted-foreground">Categories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plugin.categories.length > 0 ? (
|
||||
plugin.categories.map((category) => (
|
||||
<Badge key={category} variant="outline" className="capitalize">
|
||||
{category}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
</div>
|
||||
{hasCustomSettingsPage ? (
|
||||
<div className="space-y-3">
|
||||
{pluginSlots.map((slot) => (
|
||||
<PluginSlotMount
|
||||
key={`${slot.pluginKey}:${slot.id}`}
|
||||
slot={slot}
|
||||
context={{
|
||||
companyId: selectedCompanyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : hasConfigSchema ? (
|
||||
<PluginConfigForm
|
||||
pluginId={pluginId!}
|
||||
schema={configSchema!}
|
||||
initialValues={configData?.configJson}
|
||||
isLoading={configLoading}
|
||||
pluginStatus={plugin.status}
|
||||
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This plugin does not require any settings.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-1.5">
|
||||
<Cpu className="h-4 w-4" />
|
||||
Runtime Dashboard
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Worker process, scheduled jobs, and webhook deliveries
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{dashboardData ? (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Worker Process
|
||||
</h3>
|
||||
{dashboardData.worker ? (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant={dashboardData.worker.status === "running" ? "default" : "secondary"}>
|
||||
{dashboardData.worker.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">PID</span>
|
||||
<span className="font-mono text-xs">{dashboardData.worker.pid ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Uptime</span>
|
||||
<span className="text-xs">{formatUptime(dashboardData.worker.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pending RPCs</span>
|
||||
<span className="text-xs">{dashboardData.worker.pendingRequests}</span>
|
||||
</div>
|
||||
{dashboardData.worker.totalCrashes > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between col-span-2">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
Crashes
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{dashboardData.worker.consecutiveCrashes} consecutive / {dashboardData.worker.totalCrashes} total
|
||||
</span>
|
||||
</div>
|
||||
{dashboardData.worker.lastCrashAt && (
|
||||
<div className="flex justify-between col-span-2">
|
||||
<span className="text-muted-foreground">Last Crash</span>
|
||||
<span className="text-xs">{formatTimestamp(dashboardData.worker.lastCrashAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No worker process registered.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Recent Job Runs
|
||||
</h3>
|
||||
{dashboardData.recentJobRuns.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{dashboardData.recentJobRuns.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<JobStatusDot status={run.status} />
|
||||
<span className="truncate font-mono text-xs" title={run.jobKey ?? run.jobId}>
|
||||
{run.jobKey ?? run.jobId.slice(0, 8)}
|
||||
</span>
|
||||
<Badge variant="outline" className="px-1 py-0 text-[10px]">
|
||||
{run.trigger}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{run.durationMs != null ? <span>{formatDuration(run.durationMs)}</span> : null}
|
||||
<span title={run.createdAt}>{formatRelativeTime(run.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No job runs recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Recent Webhook Deliveries
|
||||
</h3>
|
||||
{dashboardData.recentWebhookDeliveries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{dashboardData.recentWebhookDeliveries.map((delivery) => (
|
||||
<div
|
||||
key={delivery.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DeliveryStatusDot status={delivery.status} />
|
||||
<span className="truncate font-mono text-xs" title={delivery.webhookKey}>
|
||||
{delivery.webhookKey}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{delivery.durationMs != null ? <span>{formatDuration(delivery.durationMs)}</span> : null}
|
||||
<span title={delivery.createdAt}>{formatRelativeTime(delivery.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No webhook deliveries recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Last checked: {new Date(dashboardData.checkedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Runtime diagnostics are unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{recentLogs && recentLogs.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-1.5">
|
||||
<ActivitySquare className="h-4 w-4" />
|
||||
Recent Logs
|
||||
</CardTitle>
|
||||
<CardDescription>Last {recentLogs.length} log entries</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto font-mono text-xs">
|
||||
{recentLogs.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex gap-2 py-0.5 ${
|
||||
entry.level === "error"
|
||||
? "text-destructive"
|
||||
: entry.level === "warn"
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: entry.level === "debug"
|
||||
? "text-muted-foreground/60"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground/50">{new Date(entry.createdAt).toLocaleTimeString()}</span>
|
||||
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">{entry.level}</Badge>
|
||||
<span className="truncate" title={entry.message}>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-1.5">
|
||||
<ActivitySquare className="h-4 w-4" />
|
||||
Health Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Checking health...</p>
|
||||
) : healthData ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Overall</span>
|
||||
<Badge variant={healthData.healthy ? "default" : "destructive"}>
|
||||
{healthData.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{healthData.checks.length > 0 ? (
|
||||
<div className="space-y-2 border-t border-border/50 pt-2">
|
||||
{healthData.checks.map((check, i) => (
|
||||
<div key={i} className="flex items-start justify-between gap-2">
|
||||
<span className="truncate text-muted-foreground" title={check.name}>
|
||||
{check.name}
|
||||
</span>
|
||||
{check.passed ? (
|
||||
<CheckCircle className="h-4 w-4 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{healthData.lastError ? (
|
||||
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{healthData.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Lifecycle</span>
|
||||
<Badge variant={statusVariant}>{displayStatus}</Badge>
|
||||
</div>
|
||||
<p>Health checks run once the plugin is ready.</p>
|
||||
{plugin.lastError ? (
|
||||
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{plugin.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>Plugin ID</span>
|
||||
<span className="font-mono text-xs text-right">{plugin.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>Plugin Key</span>
|
||||
<span className="font-mono text-xs text-right">{plugin.pluginKey}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>NPM Package</span>
|
||||
<span className="max-w-[170px] truncate text-right text-xs" title={plugin.packageName}>
|
||||
{plugin.packageName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>Version</span>
|
||||
<span className="text-right text-foreground">v{plugin.manifestJson.version ?? plugin.version}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-1.5">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Permissions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pluginCapabilities.length > 0 ? (
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{pluginCapabilities.map((cap) => (
|
||||
<li key={cap} className="rounded-md bg-muted/40 px-2.5 py-2 font-mono text-xs text-foreground/85">
|
||||
{cap}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No special permissions requested.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginConfigFormProps {
|
||||
pluginId: string;
|
||||
schema: JsonSchemaNode;
|
||||
initialValues?: Record<string, unknown>;
|
||||
isLoading?: boolean;
|
||||
/** Current plugin lifecycle status — "Test Configuration" only available when `ready`. */
|
||||
pluginStatus?: string;
|
||||
/** Whether the plugin worker implements `validateConfig`. */
|
||||
supportsConfigTest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that manages form state, validation, save, and "Test Configuration"
|
||||
* for the auto-generated plugin config form.
|
||||
*
|
||||
* Separated from PluginSettings to isolate re-render scope — only the form
|
||||
* re-renders on field changes, not the entire page.
|
||||
*/
|
||||
function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form values: start with saved values, fall back to schema defaults
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => ({
|
||||
...getDefaultValues(schema),
|
||||
...(initialValues ?? {}),
|
||||
}));
|
||||
|
||||
// Sync when saved config loads asynchronously — only on first load so we
|
||||
// don't overwrite in-progress user edits if the query refetches (e.g. on
|
||||
// window focus).
|
||||
const hasHydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialValues && !hasHydratedRef.current) {
|
||||
hasHydratedRef.current = true;
|
||||
setValues({
|
||||
...getDefaultValues(schema),
|
||||
...initialValues,
|
||||
});
|
||||
}
|
||||
}, [initialValues, schema]);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Dirty tracking: compare against initial values
|
||||
const isDirty = JSON.stringify(values) !== JSON.stringify({
|
||||
...getDefaultValues(schema),
|
||||
...(initialValues ?? {}),
|
||||
});
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configJson: Record<string, unknown>) =>
|
||||
pluginsApi.saveConfig(pluginId, configJson),
|
||||
onSuccess: () => {
|
||||
setSaveMessage({ type: "success", text: "Configuration saved." });
|
||||
setTestResult(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
|
||||
// Clear success message after 3s
|
||||
setTimeout(() => setSaveMessage(null), 3000);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMessage({ type: "error", text: err.message || "Failed to save configuration." });
|
||||
},
|
||||
});
|
||||
|
||||
// Test configuration mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (configJson: Record<string, unknown>) =>
|
||||
pluginsApi.testConfig(pluginId, configJson),
|
||||
onSuccess: (result) => {
|
||||
if (result.valid) {
|
||||
setTestResult({ type: "success", text: "Configuration test passed." });
|
||||
} else {
|
||||
setTestResult({ type: "error", text: result.message || "Configuration test failed." });
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setTestResult({ type: "error", text: err.message || "Configuration test failed." });
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = useCallback((newValues: Record<string, unknown>) => {
|
||||
setValues(newValues);
|
||||
// Clear field-level errors as the user types
|
||||
setErrors({});
|
||||
setSaveMessage(null);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// Validate before saving
|
||||
const validationErrors = validateJsonSchemaForm(schema, values);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
saveMutation.mutate(values);
|
||||
}, [schema, values, saveMutation]);
|
||||
|
||||
const handleTestConnection = useCallback(() => {
|
||||
// Validate before testing
|
||||
const validationErrors = validateJsonSchemaForm(schema, values);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setTestResult(null);
|
||||
testMutation.mutate(values);
|
||||
}, [schema, values, testMutation]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading configuration...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Status messages */}
|
||||
{saveMessage && (
|
||||
<div
|
||||
className={`text-sm p-2 rounded border ${
|
||||
saveMessage.type === "success"
|
||||
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
||||
: "text-destructive bg-destructive/10 border-destructive/20"
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`text-sm p-2 rounded border ${
|
||||
testResult.type === "success"
|
||||
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
||||
: "text-destructive bg-destructive/10 border-destructive/20"
|
||||
}`}
|
||||
>
|
||||
{testResult.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !isDirty}
|
||||
size="sm"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Configuration"
|
||||
)}
|
||||
</Button>
|
||||
{pluginStatus === "ready" && supportsConfigTest && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
"Test Configuration"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard helper components and formatting utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format an uptime value (in milliseconds) to a human-readable string.
|
||||
*/
|
||||
function formatUptime(uptimeMs: number | null): string {
|
||||
if (uptimeMs == null) return "—";
|
||||
const totalSeconds = Math.floor(uptimeMs / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a compact display string.
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a relative time string (e.g., "2m ago").
|
||||
*/
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoString).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
if (diffMs < 0) return "just now";
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a unix timestamp (ms since epoch) to a locale string.
|
||||
*/
|
||||
function formatTimestamp(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot for job run statuses.
|
||||
*/
|
||||
function JobStatusDot({ status }: { status: string }) {
|
||||
const colorClass =
|
||||
status === "success" || status === "succeeded"
|
||||
? "bg-green-500"
|
||||
: status === "failed"
|
||||
? "bg-red-500"
|
||||
: status === "running"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: status === "cancelled"
|
||||
? "bg-gray-400"
|
||||
: "bg-amber-500"; // queued, pending
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot for webhook delivery statuses.
|
||||
*/
|
||||
function DeliveryStatusDot({ status }: { status: string }) {
|
||||
const colorClass =
|
||||
status === "processed" || status === "success"
|
||||
? "bg-green-500"
|
||||
: status === "failed"
|
||||
? "bg-red-500"
|
||||
: status === "received"
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500"; // pending
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
|
||||
@@ -11,20 +11,26 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties } from "../components/ProjectProperties";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { projectRouteRef, cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectTab = "overview" | "list";
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
|
||||
return typeof value === "string" && value.startsWith("plugin:");
|
||||
}
|
||||
|
||||
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
@@ -32,6 +38,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
||||
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
|
||||
const tab = segments[projectsIdx + 2];
|
||||
if (tab === "overview") return "overview";
|
||||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "issues") return "list";
|
||||
return null;
|
||||
}
|
||||
@@ -198,12 +205,14 @@ export function ProjectDetail() {
|
||||
filter?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
|
||||
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
|
||||
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -212,8 +221,12 @@ export function ProjectDetail() {
|
||||
}, [companies, companyPrefix]);
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||
|
||||
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||||
const activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||||
const pluginTabFromSearch = useMemo(() => {
|
||||
const tab = new URLSearchParams(location.search).get("tab");
|
||||
return isProjectPluginTab(tab) ? tab : null;
|
||||
}, [location.search]);
|
||||
const activeTab = activeRouteTab ?? pluginTabFromSearch;
|
||||
|
||||
const { data: project, isLoading, error } = useQuery({
|
||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||
@@ -223,6 +236,24 @@ export function ProjectDetail() {
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||
const {
|
||||
slots: pluginDetailSlots,
|
||||
isLoading: pluginDetailSlotsLoading,
|
||||
} = usePluginSlots({
|
||||
slotTypes: ["detailTab"],
|
||||
entityType: "project",
|
||||
companyId: resolvedCompanyId,
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
const pluginTabItems = useMemo(
|
||||
() => pluginDetailSlots.map((slot) => ({
|
||||
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab,
|
||||
label: slot.displayName,
|
||||
slot,
|
||||
})),
|
||||
[pluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
@@ -243,6 +274,21 @@ export function ProjectDetail() {
|
||||
onSuccess: invalidateProject,
|
||||
});
|
||||
|
||||
const archiveProject = useMutation({
|
||||
mutationFn: (archived: boolean) =>
|
||||
projectsApi.update(
|
||||
projectLookupRef,
|
||||
{ archivedAt: archived ? new Date().toISOString() : null },
|
||||
resolvedCompanyId ?? lookupCompanyId,
|
||||
),
|
||||
onSuccess: (_, archived) => {
|
||||
invalidateProject();
|
||||
if (archived) {
|
||||
navigate("/projects");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||
@@ -260,10 +306,18 @@ export function ProjectDetail() {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
if (isProjectPluginTab(activeTab)) {
|
||||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
@@ -276,11 +330,56 @@ export function ProjectDetail() {
|
||||
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
|
||||
}
|
||||
closePanel();
|
||||
return () => closePanel();
|
||||
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [closePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(fieldSaveTimers.current).forEach((timer) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setFieldState = useCallback((field: ProjectConfigFieldKey, state: ProjectFieldSaveState) => {
|
||||
setFieldSaveStates((current) => ({ ...current, [field]: state }));
|
||||
}, []);
|
||||
|
||||
const scheduleFieldReset = useCallback((field: ProjectConfigFieldKey, delayMs: number) => {
|
||||
const existing = fieldSaveTimers.current[field];
|
||||
if (existing) clearTimeout(existing);
|
||||
fieldSaveTimers.current[field] = setTimeout(() => {
|
||||
setFieldSaveStates((current) => {
|
||||
const next = { ...current };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
delete fieldSaveTimers.current[field];
|
||||
}, delayMs);
|
||||
}, []);
|
||||
|
||||
const updateProjectField = useCallback(async (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
|
||||
const requestId = (fieldSaveRequestIds.current[field] ?? 0) + 1;
|
||||
fieldSaveRequestIds.current[field] = requestId;
|
||||
setFieldState(field, "saving");
|
||||
try {
|
||||
await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId);
|
||||
invalidateProject();
|
||||
if (fieldSaveRequestIds.current[field] !== requestId) return;
|
||||
setFieldState(field, "saved");
|
||||
scheduleFieldReset(field, 1800);
|
||||
} catch (error) {
|
||||
if (fieldSaveRequestIds.current[field] !== requestId) return;
|
||||
setFieldState(field, "error");
|
||||
scheduleFieldReset(field, 3000);
|
||||
throw error;
|
||||
}
|
||||
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||||
|
||||
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
// Redirect bare /projects/:id to /projects/:id/issues
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
@@ -292,8 +391,14 @@ export function ProjectDetail() {
|
||||
if (!project) return null;
|
||||
|
||||
const handleTabChange = (tab: ProjectTab) => {
|
||||
if (isProjectPluginTab(tab)) {
|
||||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
||||
return;
|
||||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||
} else {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues`);
|
||||
}
|
||||
@@ -314,54 +419,56 @@ export function ProjectDetail() {
|
||||
as="h2"
|
||||
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>
|
||||
|
||||
{/* Top-level project tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
activeTab === "overview"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleTabChange("overview")}
|
||||
>
|
||||
Overview
|
||||
</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>
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="project"
|
||||
context={{
|
||||
companyId: resolvedCompanyId ?? null,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
projectRef: canonicalProjectRef,
|
||||
entityId: project.id,
|
||||
entityType: "project",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
|
||||
<PluginLauncherOutlet
|
||||
placementZones={["toolbarButton"]}
|
||||
entityType="project"
|
||||
context={{
|
||||
companyId: resolvedCompanyId ?? null,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
projectRef: canonicalProjectRef,
|
||||
entityId: project.id,
|
||||
entityType: "project",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
})),
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab ?? "list"}
|
||||
onValueChange={(value) => handleTabChange(value as ProjectTab)}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "overview" && (
|
||||
<OverviewContent
|
||||
project={project}
|
||||
@@ -377,19 +484,33 @@ export function ProjectDetail() {
|
||||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
<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>
|
||||
{activeTab === "configuration" && (
|
||||
<div className="max-w-4xl">
|
||||
<ProjectProperties
|
||||
project={project}
|
||||
onUpdate={(data) => updateProject.mutate(data)}
|
||||
onFieldUpdate={updateProjectField}
|
||||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||||
onArchive={(archived) => archiveProject.mutate(archived)}
|
||||
archivePending={archiveProject.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePluginTab && (
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
companyId: resolvedCompanyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
projectRef: canonicalProjectRef,
|
||||
entityId: project.id,
|
||||
entityType: "project",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -22,11 +22,15 @@ export function Projects() {
|
||||
setBreadcrumbs([{ label: "Projects" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: projects, isLoading, error } = useQuery({
|
||||
const { data: allProjects, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const projects = useMemo(
|
||||
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||
@@ -47,7 +51,7 @@ export function Projects() {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
{!isLoading && projects.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Hexagon}
|
||||
message="No projects yet."
|
||||
@@ -56,7 +60,7 @@ export function Projects() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
{projects.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{projects.map((project) => (
|
||||
<EntityRow
|
||||
|
||||
334
ui/src/pages/RunTranscriptUxLab.tsx
Normal file
334
ui/src/pages/RunTranscriptUxLab.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn, formatDateTime } from "../lib/utils";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures";
|
||||
import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower } from "lucide-react";
|
||||
|
||||
type SurfaceId = "detail" | "live" | "dashboard";
|
||||
|
||||
const surfaceOptions: Array<{
|
||||
id: SurfaceId;
|
||||
label: string;
|
||||
eyebrow: string;
|
||||
description: string;
|
||||
icon: typeof LayoutPanelLeft;
|
||||
}> = [
|
||||
{
|
||||
id: "detail",
|
||||
label: "Run Detail",
|
||||
eyebrow: "Full transcript",
|
||||
description: "The long-form run page with the `Nice | Raw` toggle and the most inspectable transcript view.",
|
||||
icon: MonitorCog,
|
||||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Issue Widget",
|
||||
eyebrow: "Live stream",
|
||||
description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||
icon: RadioTower,
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard Card",
|
||||
eyebrow: "Dense card",
|
||||
description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.",
|
||||
icon: PanelsTopLeft,
|
||||
},
|
||||
];
|
||||
|
||||
function previewEntries(surface: SurfaceId) {
|
||||
if (surface === "dashboard") {
|
||||
return runTranscriptFixtureEntries.slice(-9);
|
||||
}
|
||||
if (surface === "live") {
|
||||
return runTranscriptFixtureEntries.slice(-14);
|
||||
}
|
||||
return runTranscriptFixtureEntries;
|
||||
}
|
||||
|
||||
function RunDetailPreview({
|
||||
mode,
|
||||
streaming,
|
||||
density,
|
||||
}: {
|
||||
mode: TranscriptMode;
|
||||
streaming: boolean;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-border/60 bg-background/90 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="uppercase tracking-[0.18em] text-[10px]">
|
||||
Run Detail
|
||||
</Badge>
|
||||
<StatusBadge status={streaming ? "running" : "succeeded"} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(runTranscriptFixtureMeta.startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
Transcript ({runTranscriptFixtureEntries.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[720px] overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)] p-5">
|
||||
<RunTranscriptView
|
||||
entries={runTranscriptFixtureEntries}
|
||||
mode={mode}
|
||||
density={density}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveWidgetPreview({
|
||||
streaming,
|
||||
mode,
|
||||
density,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
mode: TranscriptMode;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-cyan-500/25 bg-background/85 shadow-[0_20px_50px_rgba(6,182,212,0.10)]">
|
||||
<div className="border-b border-border/60 bg-cyan-500/[0.05] px-5 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
|
||||
Live Runs
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Compact live transcript stream for the issue detail page.
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Identity name={runTranscriptFixtureMeta.agentName} size="sm" />
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 font-mono">
|
||||
{runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
|
||||
</span>
|
||||
<StatusBadge status={streaming ? "running" : "succeeded"} />
|
||||
<span>{formatDateTime(runTranscriptFixtureMeta.startedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
Open run
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[460px] overflow-y-auto pr-1">
|
||||
<RunTranscriptView
|
||||
entries={previewEntries("live")}
|
||||
mode={mode}
|
||||
density={density}
|
||||
limit={density === "compact" ? 10 : 12}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPreview({
|
||||
streaming,
|
||||
mode,
|
||||
density,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
mode: TranscriptMode;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<div className={cn(
|
||||
"flex h-[320px] flex-col overflow-hidden rounded-xl border shadow-[0_20px_40px_rgba(15,23,42,0.10)]",
|
||||
streaming
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.04]"
|
||||
: "border-border bg-background/75",
|
||||
)}>
|
||||
<div className="border-b border-border/60 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex h-2.5 w-2.5 rounded-full",
|
||||
streaming ? "bg-cyan-500 shadow-[0_0_0_6px_rgba(34,211,238,0.12)]" : "bg-muted-foreground/35",
|
||||
)} />
|
||||
<Identity name={runTranscriptFixtureMeta.agentName} size="sm" />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
{streaming ? "Live now" : "Finished 2m ago"}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-3 py-2 text-xs text-cyan-700 dark:text-cyan-300">
|
||||
{runTranscriptFixtureMeta.issueIdentifier} - {runTranscriptFixtureMeta.issueTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<RunTranscriptView
|
||||
entries={previewEntries("dashboard")}
|
||||
mode={mode}
|
||||
density={density}
|
||||
limit={density === "compact" ? 6 : 8}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunTranscriptUxLab() {
|
||||
const [selectedSurface, setSelectedSurface] = useState<SurfaceId>("detail");
|
||||
const [detailMode, setDetailMode] = useState<TranscriptMode>("nice");
|
||||
const [streaming, setStreaming] = useState(true);
|
||||
const [density, setDensity] = useState<TranscriptDensity>("comfortable");
|
||||
|
||||
const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_40%),var(--background)] shadow-[0_28px_70px_rgba(15,23,42,0.10)]">
|
||||
<div className="grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border/60 bg-background/75 p-5 lg:border-b-0 lg:border-r">
|
||||
<div className="mb-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
UX Lab
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight">Run Transcript Fixtures</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Built from a real Paperclip development run, then sanitized so no secrets, local paths, or environment details survive into the fixture.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{surfaceOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSurface(option.id)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-4 py-3 text-left transition-all",
|
||||
selectedSurface === option.id
|
||||
? "border-cyan-500/35 bg-cyan-500/[0.10] shadow-[0_12px_24px_rgba(6,182,212,0.12)]"
|
||||
: "border-border/70 bg-background/70 hover:border-cyan-500/20 hover:bg-cyan-500/[0.04]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="rounded-lg border border-current/15 p-2 text-cyan-700 dark:text-cyan-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{option.eyebrow}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm font-medium">{option.label}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 p-5">
|
||||
<div className="mb-5 flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{selected.eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-2xl font-semibold">{selected.label}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
{selected.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
{runTranscriptFixtureMeta.issueIdentifier}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Controls
|
||||
</span>
|
||||
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
|
||||
{(["nice", "raw"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
|
||||
detailMode === mode ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setDetailMode(mode)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
|
||||
{(["comfortable", "compact"] as const).map((nextDensity) => (
|
||||
<button
|
||||
key={nextDensity}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
|
||||
density === nextDensity ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setDensity(nextDensity)}
|
||||
>
|
||||
{nextDensity}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => setStreaming((value) => !value)}
|
||||
>
|
||||
{streaming ? "Show settled state" : "Show streaming state"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedSurface === "detail" ? (
|
||||
<div className={cn(density === "compact" && "max-w-5xl")}>
|
||||
<RunDetailPreview mode={detailMode} streaming={streaming} density={density} />
|
||||
</div>
|
||||
) : selectedSurface === "live" ? (
|
||||
<div className={cn(density === "compact" && "max-w-4xl")}>
|
||||
<LiveWidgetPreview streaming={streaming} mode={detailMode} density={density} />
|
||||
</div>
|
||||
) : (
|
||||
<DashboardPreview streaming={streaming} mode={detailMode} density={density} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user