Files
paperclip/ui/src/pages/AgentDetail.tsx
2026-03-18 08:10:36 -05:00

3795 lines
147 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, useRef } from "react";
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 { companySkillsApi } from "../api/companySkills";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
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, help } from "../components/agent-config-primitives";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript } from "../adapters";
import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
MoreHorizontal,
Play,
Pause,
CheckCircle2,
XCircle,
Clock,
Timer,
Loader2,
Slash,
RotateCcw,
Trash2,
Plus,
Key,
Eye,
EyeOff,
Copy,
ChevronRight,
ChevronDown,
ArrowLeft,
HelpCircle,
} from "lucide-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
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 AgentSkillSnapshot,
type BudgetPolicySummary,
type HeartbeatRun,
type HeartbeatRunEvent,
type AgentRuntimeState,
type LiveEvent,
type WorkspaceOperation,
} from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import { applyAgentSkillSnapshot, arraysEqual } from "../lib/agent-skills-state";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
};
const REDACTED_ENV_VALUE = "***REDACTED***";
const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
function shouldRedactSecretValue(key: string, value: unknown): boolean {
if (SECRET_ENV_KEY_RE.test(key)) return true;
if (typeof value !== "string") return false;
return JWT_VALUE_RE.test(value);
}
function redactEnvValue(key: string, value: unknown): string {
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(value as { type?: unknown }).type === "secret_ref"
) {
return "***SECRET_REF***";
}
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return "";
if (typeof value === "string") return redactHomePathUserSegments(value);
try {
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
} catch {
return redactHomePathUserSegments(String(value));
}
}
function isMarkdown(pathValue: string) {
return pathValue.toLowerCase().endsWith(".md");
}
function formatEnvForDisplay(envValue: unknown): string {
const env = asRecord(envValue);
if (!env) return "<unable-to-parse>";
const keys = Object.keys(env);
if (keys.length === 0) return "<empty>";
return keys
.sort()
.map((key) => `${key}=${redactEnvValue(key, env[key])}`)
.join("\n");
}
const sourceLabels: Record<string, string> = {
timer: "Timer",
assignment: "Assignment",
on_demand: "On-demand",
automation: "Automation",
};
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
type ScrollContainer = Window | HTMLElement;
function isWindowContainer(container: ScrollContainer): container is Window {
return container === window;
}
function isElementScrollContainer(element: HTMLElement): boolean {
const overflowY = window.getComputedStyle(element).overflowY;
return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
}
function findScrollContainer(anchor: HTMLElement | null): ScrollContainer {
let parent = anchor?.parentElement ?? null;
while (parent) {
if (isElementScrollContainer(parent)) return parent;
parent = parent.parentElement;
}
return window;
}
function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } {
if (isWindowContainer(container)) {
const pageHeight = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
);
const viewportBottom = window.scrollY + window.innerHeight;
return {
scrollHeight: pageHeight,
distanceFromBottom: Math.max(0, pageHeight - viewportBottom),
};
}
const viewportBottom = container.scrollTop + container.clientHeight;
return {
scrollHeight: container.scrollHeight,
distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom),
};
}
function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") {
if (isWindowContainer(container)) {
const pageHeight = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
);
window.scrollTo({ top: pageHeight, behavior });
return;
}
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "instructions" || value === "prompts") return "instructions";
if (value === "configure" || value === "configuration") return "configuration";
if (value === "skills") return "skills";
if (value === "budget") return "budget";
if (value === "runs") return value;
return "dashboard";
}
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
if (!usage) return 0;
for (const key of keys) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return 0;
}
function setsEqual<T>(left: Set<T>, right: Set<T>) {
if (left.size !== right.size) return false;
for (const value of left) {
if (!right.has(value)) return false;
}
return true;
}
function runMetrics(run: HeartbeatRun) {
const usage = (run.usageJson ?? null) as Record<string, unknown> | null;
const result = (run.resultJson ?? null) as Record<string, unknown> | null;
const input = usageNumber(usage, "inputTokens", "input_tokens");
const output = usageNumber(usage, "outputTokens", "output_tokens");
const cached = usageNumber(
usage,
"cachedInputTokens",
"cached_input_tokens",
"cache_read_input_tokens",
);
const cost =
visibleRunCostUsd(usage, result);
return {
input,
output,
cached,
cost,
totalTokens: input + output,
};
}
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
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 asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseStoredLogContent(content: string): RunLogChunk[] {
const parsed: RunLogChunk[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const stream =
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
} catch {
// Ignore malformed log lines.
}
}
return parsed;
}
function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) {
switch (phase) {
case "worktree_prepare":
return "Worktree setup";
case "workspace_provision":
return "Provision";
case "workspace_teardown":
return "Teardown";
case "worktree_cleanup":
return "Worktree cleanup";
default:
return phase;
}
}
function workspaceOperationStatusTone(status: WorkspaceOperation["status"]) {
switch (status) {
case "succeeded":
return "border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300";
case "failed":
return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300";
case "running":
return "border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300";
case "skipped":
return "border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300";
default:
return "border-border bg-muted/40 text-muted-foreground";
}
}
function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation["status"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium capitalize",
workspaceOperationStatusTone(status),
)}
>
{status.replace("_", " ")}
</span>
);
}
function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperation }) {
const [open, setOpen] = useState(false);
const { data: logData, isLoading, error } = useQuery({
queryKey: ["workspace-operation-log", operation.id],
queryFn: () => heartbeatsApi.workspaceOperationLog(operation.id),
enabled: open && Boolean(operation.logRef),
refetchInterval: open && operation.status === "running" ? 2000 : false,
});
const chunks = useMemo(
() => (logData?.content ? parseStoredLogContent(logData.content) : []),
[logData?.content],
);
return (
<div className="space-y-2">
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setOpen((value) => !value)}
>
{open ? "Hide full log" : "Show full log"}
</button>
{open && (
<div className="rounded-md border border-border bg-background/70 p-2">
{isLoading && <div className="text-xs text-muted-foreground">Loading log...</div>}
{error && (
<div className="text-xs text-destructive">
{error instanceof Error ? error.message : "Failed to load workspace operation log"}
</div>
)}
{!isLoading && !error && chunks.length === 0 && (
<div className="text-xs text-muted-foreground">No persisted log lines.</div>
)}
{chunks.length > 0 && (
<div className="max-h-64 overflow-y-auto rounded bg-neutral-100 p-2 font-mono text-xs dark:bg-neutral-950">
{chunks.map((chunk, index) => (
<div key={`${chunk.ts}-${index}`} className="flex gap-2">
<span className="shrink-0 text-neutral-500">
{new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })}
</span>
<span
className={cn(
"shrink-0 w-14",
chunk.stream === "stderr"
? "text-red-600 dark:text-red-300"
: chunk.stream === "system"
? "text-blue-600 dark:text-blue-300"
: "text-muted-foreground",
)}
>
[{chunk.stream}]
</span>
<span className="whitespace-pre-wrap break-all">{redactHomePathUserSegments(chunk.chunk)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) {
if (operations.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-3">
<div className="text-xs font-medium text-muted-foreground">
Workspace ({operations.length})
</div>
<div className="space-y-3">
{operations.map((operation) => {
const metadata = asRecord(operation.metadata);
return (
<div key={operation.id} className="rounded-md border border-border/70 bg-background/70 p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{workspaceOperationPhaseLabel(operation.phase)}</div>
<WorkspaceOperationStatusBadge status={operation.status} />
<div className="text-[11px] text-muted-foreground">
{relativeTime(operation.startedAt)}
{operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`}
</div>
</div>
{operation.command && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">{operation.command}</span>
</div>
)}
{operation.cwd && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Working dir: </span>
<span className="font-mono">{operation.cwd}</span>
</div>
)}
{(asNonEmptyString(metadata?.branchName)
|| asNonEmptyString(metadata?.baseRef)
|| asNonEmptyString(metadata?.worktreePath)
|| asNonEmptyString(metadata?.repoRoot)
|| asNonEmptyString(metadata?.cleanupAction)) && (
<div className="grid gap-1 text-xs sm:grid-cols-2">
{asNonEmptyString(metadata?.branchName) && (
<div><span className="text-muted-foreground">Branch: </span><span className="font-mono">{metadata?.branchName as string}</span></div>
)}
{asNonEmptyString(metadata?.baseRef) && (
<div><span className="text-muted-foreground">Base ref: </span><span className="font-mono">{metadata?.baseRef as string}</span></div>
)}
{asNonEmptyString(metadata?.worktreePath) && (
<div className="break-all"><span className="text-muted-foreground">Worktree: </span><span className="font-mono">{metadata?.worktreePath as string}</span></div>
)}
{asNonEmptyString(metadata?.repoRoot) && (
<div className="break-all"><span className="text-muted-foreground">Repo root: </span><span className="font-mono">{metadata?.repoRoot as string}</span></div>
)}
{asNonEmptyString(metadata?.cleanupAction) && (
<div><span className="text-muted-foreground">Cleanup: </span><span className="font-mono">{metadata?.cleanupAction as string}</span></div>
)}
</div>
)}
{typeof metadata?.created === "boolean" && (
<div className="text-xs text-muted-foreground">
{metadata.created ? "Created by this run" : "Reused existing workspace"}
</div>
)}
{operation.stderrExcerpt && operation.stderrExcerpt.trim() && (
<div>
<div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
<pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
{redactHomePathUserSegments(operation.stderrExcerpt)}
</pre>
</div>
)}
{operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && (
<div>
<div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
<pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
{redactHomePathUserSegments(operation.stdoutExcerpt)}
</pre>
</div>
)}
{operation.logRef && <WorkspaceOperationLogViewer operation={operation} />}
</div>
);
})}
</div>
</div>
);
}
export function AgentDetail() {
const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
companyPrefix?: string;
agentId: string;
tab?: string;
runId?: string;
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
const needsDashboardData = activeView === "dashboard";
const needsRunData = activeView === "runs" || Boolean(urlRunId);
const shouldLoadHeartbeats = needsDashboardData || needsRunData;
const [configDirty, setConfigDirty] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar();
const routeAgentRef = agentId ?? "";
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId));
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const { data: agent, isLoading, error } = useQuery({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
enabled: canFetchAgent,
});
const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
const agentLookupRef = agent?.id ?? routeAgentRef;
const resolvedAgentId = agent?.id ?? null;
const { data: runtimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined),
enabled: Boolean(resolvedAgentId) && needsDashboardData,
});
const { data: heartbeats } = useQuery({
queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined),
queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined),
enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats,
});
const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(resolvedCompanyId!),
queryFn: () => issuesApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId && needsDashboardData,
});
const { data: allAgents } = useQuery({
queryKey: queryKeys.agents.list(resolvedCompanyId!),
queryFn: () => agentsApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId && needsDashboardData,
});
const { data: budgetOverview } = useQuery({
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
refetchInterval: 30_000,
staleTime: 5_000,
});
const assignedIssues = (allIssues ?? [])
.filter((i) => i.assigneeAgentId === agent?.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
const agentBudgetSummary = useMemo(() => {
const matched = budgetOverview?.policies.find(
(policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
);
if (matched) return matched;
const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
return {
policyId: "",
companyId: resolvedCompanyId ?? "",
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
scopeName: agent?.name ?? "Agent",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: budgetMonthlyCents,
observedAmount: spentMonthlyCents,
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
utilizationPercent:
budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: budgetMonthlyCents > 0,
status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
paused: agent?.status === "paused",
pauseReason: agent?.pauseReason ?? null,
windowStart: new Date(),
windowEnd: new Date(),
} satisfies BudgetPolicySummary;
}, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats],
);
useEffect(() => {
if (!agent) return;
if (urlRunId) {
if (routeAgentRef !== canonicalAgentRef) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
}
return;
}
const canonicalTab =
activeView === "instructions"
? "instructions"
: activeView === "configuration"
? "configuration"
: activeView === "skills"
? "skills"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return;
}
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
switch (action) {
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
}
},
onSuccess: (data, action) => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
if (agent?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
}
}
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
}
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Action failed");
},
});
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
amount,
windowKind: "calendar_month_utc",
}),
onSuccess: () => {
if (!resolvedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
},
});
const updateIcon = useMutation({
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
},
});
const resetTaskSession = useMutation({
mutationFn: (taskKey: string | null) =>
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reset session");
},
});
const updatePermissions = useMutation({
mutationFn: (canCreateAgents: boolean) =>
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to update permissions");
},
});
useEffect(() => {
const crumbs: { label: string; href?: string }[] = [
{ label: "Agents", href: "/agents" },
];
const agentName = agent?.name ?? routeAgentRef ?? "Agent";
if (activeView === "dashboard" && !urlRunId) {
crumbs.push({ label: agentName });
} else {
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 === "instructions") {
crumbs.push({ label: "Instructions" });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "skills") {
crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
crumbs.push({ label: "Budget" });
} else {
crumbs.push({ label: "Dashboard" });
}
}
setBreadcrumbs(crumbs);
}, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
useEffect(() => {
closePanel();
return () => closePanel();
}, [closePanel]);
useBeforeUnload(
useCallback((event) => {
if (!configDirty) return;
event.preventDefault();
event.returnValue = "";
}, [configDirty]),
);
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 === "configuration" || activeView === "instructions") && (configDirty || configSaving);
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<AgentIconPicker
value={agent.icon}
onChange={(icon) => updateIcon.mutate(icon)}
>
<button className="shrink-0 flex items-center justify-center h-12 w-12 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
<AgentIcon icon={agent.icon} className="h-6 w-6" />
</button>
</AgentIconPicker>
<div className="min-w-0">
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
<p className="text-sm text-muted-foreground truncate">
{roleLabels[agent.role] ?? agent.role}
{agent.title ? ` - ${agent.title}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
>
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Assign Task</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending || isPendingApproval}
>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Run Heartbeat</span>
</Button>
{agent.status === "paused" ? (
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("resume")}
disabled={agentAction.isPending || isPendingApproval}
>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("pause")}
disabled={agentAction.isPending || isPendingApproval}
>
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
)}
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{mobileLiveRun && (
<Link
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<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>
</Link>
)}
{/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs">
<MoreHorizontal className="h-4 w-4" />
</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={() => {
navigator.clipboard.writeText(agent.id);
setMoreOpen(false);
}}
>
<Copy className="h-3 w-3" />
Copy Agent ID
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
resetTaskSession.mutate(null);
setMoreOpen(false);
}}
>
<RotateCcw className="h-3 w-3" />
Reset Sessions
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
</PopoverContent>
</Popover>
</div>
</div>
{!urlRunId && (
<Tabs
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
{ value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" },
]}
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">
This agent is pending board approval and cannot be invoked yet.
</p>
)}
{/* Floating Save/Cancel (desktop) */}
{!isMobile && (
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
showConfigActionBar
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving…" : "Save"}
</Button>
</div>
</div>
)}
{/* Mobile bottom Save/Cancel bar */}
{isMobile && showConfigActionBar && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur-sm">
<div
className="flex items-center justify-end gap-2 px-3 py-2"
style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
>
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving…" : "Save"}
</Button>
</div>
</div>
)}
{/* View content */}
{activeView === "dashboard" && (
<AgentOverview
agent={agent}
runs={heartbeats ?? []}
assignedIssues={assignedIssues}
runtimeState={runtimeState}
agentId={agent.id}
agentRouteId={canonicalAgentRef}
/>
)}
{activeView === "instructions" && (
<PromptsTab
agent={agent}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving}
/>
)}
{activeView === "configuration" && (
<AgentConfigurePage
agent={agent}
agentId={agent.id}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving}
updatePermissions={updatePermissions}
/>
)}
{activeView === "skills" && (
<AgentSkillsTab
agent={agent}
companyId={resolvedCompanyId ?? undefined}
/>
)}
{activeView === "runs" && (
<RunsTab
runs={heartbeats ?? []}
companyId={resolvedCompanyId!}
agentId={agent.id}
agentRouteId={canonicalAgentRef}
selectedRunId={urlRunId ?? null}
adapterType={agent.adapterType}
/>
)}
{activeView === "budget" && resolvedCompanyId ? (
<div className="max-w-3xl">
<BudgetPolicyCard
summary={agentBudgetSummary}
isSaving={budgetMutation.isPending}
onSave={(amount) => budgetMutation.mutate(amount)}
variant="plain"
/>
</div>
) : null}
</div>
);
}
/* ---- Helper components ---- */
function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">{label}</span>
<div className="flex items-center gap-1">{children}</div>
</div>
);
}
function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) {
if (runs.length === 0) return null;
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued");
const run = liveRun ?? sorted[0];
const isLive = run.status === "running" || run.status === "queued";
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon;
const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
return (
<div className="space-y-3">
<div className="flex w-full items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium">
{isLive && (
<span className="relative flex h-2 w-2">
<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>
)}
{isLive ? "Live Run" : "Latest Run"}
</h3>
<Link
to={`/agents/${agentId}/runs/${run.id}`}
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
>
View details &rarr;
</Link>
</div>
<Link
to={`/agents/${agentId}/runs/${run.id}`}
className={cn(
"block border rounded-lg p-4 space-y-2 w-full no-underline transition-colors hover:bg-muted/50 cursor-pointer",
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
)}
>
<div className="flex items-center gap-2">
<StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
<StatusBadge status={run.status} />
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
<span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-muted text-muted-foreground"
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
<span className="ml-auto text-xs text-muted-foreground">{relativeTime(run.createdAt)}</span>
</div>
{summary && (
<div className="overflow-hidden max-h-16">
<MarkdownBody className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{summary}</MarkdownBody>
</div>
)}
</Link>
</div>
);
}
/* ---- Agent Overview (main single-page view) ---- */
function AgentOverview({
agent,
runs,
assignedIssues,
runtimeState,
agentId,
agentRouteId,
}: {
agent: Agent;
runs: HeartbeatRun[];
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
runtimeState?: AgentRuntimeState;
agentId: string;
agentRouteId: string;
}) {
return (
<div className="space-y-8">
{/* Latest Run */}
<LatestRunCard runs={runs} agentId={agentRouteId} />
{/* Charts */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs} />
</ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
<PriorityChart issues={assignedIssues} />
</ChartCard>
<ChartCard title="Issues by Status" subtitle="Last 14 days">
<IssueStatusChart issues={assignedIssues} />
</ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days">
<SuccessRateChart runs={runs} />
</ChartCard>
</div>
{/* Recent Issues */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3>
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
See All &rarr;
</Link>
</div>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
) : (
<div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
trailing={<StatusBadge status={issue.status} />}
/>
))}
{assignedIssues.length > 10 && (
<div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
+{assignedIssues.length - 10} more issues
</div>
)}
</div>
)}
</div>
{/* Costs */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Costs</h3>
<CostsSection runtimeState={runtimeState} runs={runs} />
</div>
</div>
);
}
/* ---- Costs Section (inline) ---- */
function CostsSection({
runtimeState,
runs,
}: {
runtimeState?: AgentRuntimeState;
runs: HeartbeatRun[];
}) {
const runsWithCost = runs
.filter((r) => {
const metrics = runMetrics(r);
return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<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 tabular-nums">
<div>
<span className="text-xs text-muted-foreground block">Input tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Output tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalOutputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Cached tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalCachedInputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Total cost</span>
<span className="text-lg font-semibold">{formatCents(runtimeState.totalCostCents)}</span>
</div>
</div>
</div>
)}
{runsWithCost.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-accent/20">
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Date</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Run</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Input</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Output</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{runsWithCost.slice(0, 10).map((run) => {
const metrics = runMetrics(run);
return (
<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 tabular-nums">{formatTokens(metrics.input)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{metrics.cost > 0
? `$${metrics.cost.toFixed(4)}`
: "-"
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
/* ---- Agent Configure Page ---- */
function AgentConfigurePage({
agent,
agentId,
companyId,
onDirtyChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
updatePermissions,
}: {
agent: Agent;
agentId: string;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
}) {
const queryClient = useQueryClient();
const [revisionsOpen, setRevisionsOpen] = useState(false);
const { data: configRevisions } = useQuery({
queryKey: queryKeys.agents.configRevisions(agent.id),
queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
});
const rollbackConfig = useMutation({
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
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) });
},
});
return (
<div className="max-w-3xl space-y-6">
<ConfigurationTab
agent={agent}
onDirtyChange={onDirtyChange}
onSaveActionChange={onSaveActionChange}
onCancelActionChange={onCancelActionChange}
onSavingChange={onSavingChange}
updatePermissions={updatePermissions}
companyId={companyId}
hidePromptTemplate
hideInstructionsFile
/>
<div>
<h3 className="text-sm font-medium mb-3">API Keys</h3>
<KeysTab agentId={agentId} companyId={companyId} />
</div>
{/* Configuration Revisions — collapsible at the bottom */}
<div>
<button
className="flex items-center gap-2 text-sm font-medium hover:text-foreground transition-colors"
onClick={() => setRevisionsOpen((v) => !v)}
>
{revisionsOpen
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
}
Configuration Revisions
<span className="text-xs font-normal text-muted-foreground">{configRevisions?.length ?? 0}</span>
</button>
{revisionsOpen && (
<div className="mt-3">
{(configRevisions ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No configuration revisions yet.</p>
) : (
<div className="space-y-2">
{(configRevisions ?? []).slice(0, 10).map((revision) => (
<div key={revision.id} className="border border-border/70 rounded-md p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-muted-foreground">
<span className="font-mono">{revision.id.slice(0, 8)}</span>
<span className="mx-1">·</span>
<span>{formatDate(revision.createdAt)}</span>
<span className="mx-1">·</span>
<span>{revision.source}</span>
</div>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
onClick={() => rollbackConfig.mutate(revision.id)}
disabled={rollbackConfig.isPending}
>
Restore
</Button>
</div>
<p className="text-xs text-muted-foreground">
Changed:{" "}
{revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
/* ---- Configuration Tab ---- */
function ConfigurationTab({
agent,
companyId,
onDirtyChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
updatePermissions,
hidePromptTemplate,
hideInstructionsFile,
}: {
agent: Agent;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean;
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const lastAgentRef = useRef(agent);
const { data: adapterModels } = useQuery({
queryKey:
companyId
? queryKeys.agents.adapterModels(companyId, agent.adapterType)
: ["agents", "none", "adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
enabled: Boolean(companyId),
});
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(() => {
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">
<AgentConfigForm
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutate(patch)}
isSaving={isConfigSaving}
adapterModels={adapterModels}
onDirtyChange={onDirtyChange}
onSaveActionChange={onSaveActionChange}
onCancelActionChange={onCancelActionChange}
hideInlineSave
hidePromptTemplate={hidePromptTemplate}
hideInstructionsFile={hideInstructionsFile}
sectionLayout="cards"
/>
<div>
<h3 className="text-sm font-medium mb-3">Permissions</h3>
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between text-sm">
<span>Can create new agents</span>
<Button
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() =>
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
}
disabled={updatePermissions.isPending}
>
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
</Button>
</div>
</div>
</div>
</div>
);
}
/* ---- Prompts Tab ---- */
function PromptsTab({
agent,
companyId,
onDirtyChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
}: {
agent: Agent;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
}) {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
const [draft, setDraft] = useState<string | null>(null);
const [bundleDraft, setBundleDraft] = useState<{
mode: "managed" | "external";
rootPath: string;
entryFile: string;
} | null>(null);
const [newFilePath, setNewFilePath] = useState("");
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [filePanelWidth, setFilePanelWidth] = useState(260);
const containerRef = useRef<HTMLDivElement>(null);
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastFileVersionRef = useRef<string | null>(null);
const externalBundleRef = useRef<{
rootPath: string;
entryFile: string;
selectedFile: string;
} | null>(null);
const isLocal =
agent.adapterType === "claude_local" ||
agent.adapterType === "codex_local" ||
agent.adapterType === "opencode_local" ||
agent.adapterType === "pi_local" ||
agent.adapterType === "hermes_local" ||
agent.adapterType === "cursor";
const { data: bundle, isLoading: bundleLoading } = useQuery({
queryKey: queryKeys.agents.instructionsBundle(agent.id),
queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
enabled: Boolean(companyId && isLocal),
});
const persistedMode = bundle?.mode ?? "managed";
const persistedRootPath = persistedMode === "managed"
? (bundle?.managedRootPath ?? bundle?.rootPath ?? "")
: (bundle?.rootPath ?? "");
const currentMode = bundleDraft?.mode ?? persistedMode;
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
const currentRootPath = bundleDraft?.rootPath ?? persistedRootPath;
const fileOptions = useMemo(
() => bundle?.files.map((file) => file.path) ?? [],
[bundle],
);
const bundleMatchesDraft = Boolean(
bundle &&
currentMode === persistedMode &&
currentEntryFile === bundle.entryFile &&
currentRootPath === persistedRootPath,
);
const visibleFilePaths = useMemo(
() => bundleMatchesDraft
? [...new Set([currentEntryFile, ...fileOptions])]
: [currentEntryFile],
[bundleMatchesDraft, currentEntryFile, fileOptions],
);
const fileTree = useMemo(
() => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))),
[visibleFilePaths],
);
const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile);
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
enabled: Boolean(companyId && isLocal && selectedFileExists),
});
const updateBundle = useMutation({
mutationFn: (data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
}) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const saveFile = useMutation({
mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) =>
agentsApi.saveInstructionsFile(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const deleteFile = useMutation({
mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, relativePath) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to upload images");
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
useEffect(() => {
if (!bundle) return;
if (!bundleMatchesDraft) {
if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile);
return;
}
const availablePaths = bundle.files.map((file) => file.path);
if (availablePaths.length === 0) {
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
return;
}
if (!availablePaths.includes(selectedFile)) {
setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
}
}, [bundle, bundleMatchesDraft, currentEntryFile, selectedFile]);
useEffect(() => {
const nextExpanded = new Set<string>();
for (const filePath of visibleFilePaths) {
const parts = filePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!;
nextExpanded.add(currentPath);
}
}
setExpandedDirs((current) => (setsEqual(current, nextExpanded) ? current : nextExpanded));
}, [visibleFilePaths]);
useEffect(() => {
const versionKey = selectedFileExists && selectedFileDetail
? `${selectedFileDetail.path}:${selectedFileDetail.content}`
: `draft:${currentMode}:${currentRootPath}:${selectedOrEntryFile}`;
if (awaitingRefresh) {
setAwaitingRefresh(false);
setBundleDraft(null);
setDraft(null);
lastFileVersionRef.current = versionKey;
return;
}
if (lastFileVersionRef.current !== versionKey) {
setDraft(null);
lastFileVersionRef.current = versionKey;
}
}, [awaitingRefresh, currentMode, currentRootPath, selectedFileDetail, selectedFileExists, selectedOrEntryFile]);
useEffect(() => {
if (!bundle) return;
setBundleDraft((current) => {
if (current) return current;
return {
mode: persistedMode,
rootPath: persistedRootPath,
entryFile: bundle.entryFile,
};
});
}, [bundle, persistedMode, persistedRootPath]);
useEffect(() => {
if (!bundle || currentMode !== "external") return;
externalBundleRef.current = {
rootPath: currentRootPath,
entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
}, [bundle, currentEntryFile, currentMode, currentRootPath, selectedOrEntryFile]);
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
const displayValue = draft ?? currentContent;
const bundleDirty = Boolean(
bundleDraft &&
(
bundleDraft.mode !== persistedMode ||
bundleDraft.rootPath !== persistedRootPath ||
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
),
);
const fileDirty = draft !== null && draft !== currentContent;
const isDirty = bundleDirty || fileDirty;
const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
useEffect(() => {
onSaveActionChange(isDirty ? () => {
const save = async () => {
const shouldClearLegacy =
Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
if (bundleDirty && bundleDraft) {
await updateBundle.mutateAsync({
mode: bundleDraft.mode,
rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null,
entryFile: bundleDraft.entryFile,
});
}
if (fileDirty) {
await saveFile.mutateAsync({
path: selectedOrEntryFile,
content: displayValue,
clearLegacyPromptTemplate: shouldClearLegacy,
});
}
};
void save().catch(() => undefined);
} : null);
}, [
bundle,
bundleDirty,
bundleDraft,
displayValue,
fileDirty,
isDirty,
onSaveActionChange,
saveFile,
selectedOrEntryFile,
updateBundle,
]);
useEffect(() => {
onCancelActionChange(isDirty ? () => {
setDraft(null);
if (bundle) {
setBundleDraft({
mode: persistedMode,
rootPath: persistedRootPath,
entryFile: bundle.entryFile,
});
}
} : null);
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]);
const handleSeparatorDrag = useCallback((event: React.MouseEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = filePanelWidth;
const onMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const next = Math.max(180, Math.min(500, startWidth + delta));
setFilePanelWidth(next);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [filePanelWidth]);
if (!isLocal) {
return (
<div className="max-w-3xl">
<p className="text-sm text-muted-foreground">
Instructions bundles are only available for local adapters.
</p>
</div>
);
}
if (bundleLoading && !bundle) {
return <PromptsTabSkeleton />;
}
return (
<div className="max-w-6xl space-y-6">
{(bundle?.warnings ?? []).length > 0 && (
<div className="space-y-2">
{(bundle?.warnings ?? []).map((warning) => (
<div key={warning} className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
{warning}
</div>
))}
</div>
)}
<div ref={containerRef} className="flex gap-0">
<div className="border border-border rounded-lg p-3 space-y-3 shrink-0" style={{ width: filePanelWidth }}>
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Files</h4>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const candidate = newFilePath.trim();
if (!candidate) return;
setSelectedFile(candidate);
setDraft("");
setNewFilePath("");
}}
disabled={!newFilePath.trim()}
>
Add
</Button>
</div>
<div className="flex gap-2">
<Input
value={newFilePath}
onChange={(event) => setNewFilePath(event.target.value)}
placeholder="docs/TOOLS.md"
className="font-mono text-sm"
/>
</div>
<PackageFileTree
nodes={fileTree}
selectedFile={selectedOrEntryFile}
expandedDirs={expandedDirs}
checkedFiles={new Set()}
onToggleDir={(dirPath) => setExpandedDirs((current) => {
const next = new Set(current);
if (next.has(dirPath)) next.delete(dirPath);
else next.add(dirPath);
return next;
})}
onSelectFile={(filePath) => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
onToggleCheck={() => {}}
showCheckboxes={false}
renderFileExtra={(node) => {
const file = bundle?.files.find((entry) => entry.path === node.path);
if (!file) return null;
if (file.deprecated) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-3 shrink-0 rounded border border-amber-500/40 bg-amber-500/10 text-amber-200 px-1.5 py-0.5 text-[10px] uppercase tracking-wide cursor-help">
virtual file
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
Legacy inline prompt this deprecated virtual file preserves the old promptTemplate content
</TooltipContent>
</Tooltip>
);
}
return (
<span className="ml-3 shrink-0 rounded border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
{file.isEntryFile ? "entry" : `${file.size}b`}
</span>
);
}}
/>
</div>
{/* Draggable separator */}
<div
className="w-1 shrink-0 cursor-col-resize hover:bg-border active:bg-primary/50 rounded transition-colors mx-1"
onMouseDown={handleSeparatorDrag}
/>
<div className="border border-border rounded-lg p-4 space-y-3 min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
<p className="text-xs text-muted-foreground">
{selectedFileExists
? selectedFileSummary?.deprecated
? "Deprecated virtual file"
: `${selectedFileDetail?.language ?? "text"} file`
: "New file in this bundle"}
</p>
</div>
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
if (confirm(`Delete ${selectedOrEntryFile}?`)) {
deleteFile.mutate(selectedOrEntryFile, {
onSuccess: () => {
setSelectedFile(currentEntryFile);
setDraft(null);
},
});
}
}}
disabled={deleteFile.isPending}
>
Delete
</Button>
)}
</div>
{selectedFileExists && fileLoading && !selectedFileDetail ? (
<PromptEditorSkeleton />
) : isMarkdown(selectedOrEntryFile) ? (
<MarkdownEditor
key={selectedOrEntryFile}
value={displayValue}
onChange={(value) => setDraft(value ?? "")}
placeholder="# Agent instructions"
contentClassName="min-h-[420px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
) : (
<textarea
value={displayValue}
onChange={(event) => setDraft(event.target.value)}
className="min-h-[420px] w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
placeholder="File contents"
/>
)}
</div>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
Advanced
</CollapsibleTrigger>
<CollapsibleContent className="pt-4 pb-4 space-y-4">
<TooltipProvider>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
Mode
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
Managed: Paperclip stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.
</TooltipContent>
</Tooltip>
</span>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={currentMode === "managed" ? "default" : "outline"}
onClick={() => {
if (currentMode === "external") {
externalBundleRef.current = {
rootPath: currentRootPath,
entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
}
const nextEntryFile = currentEntryFile || "AGENTS.md";
setBundleDraft({
mode: "managed",
rootPath: bundle?.managedRootPath ?? currentRootPath,
entryFile: nextEntryFile,
});
setSelectedFile(nextEntryFile);
}}
>
Managed
</Button>
<Button
type="button"
size="sm"
variant={currentMode === "external" ? "default" : "outline"}
onClick={() => {
const externalBundle = externalBundleRef.current;
const nextEntryFile = externalBundle?.entryFile ?? currentEntryFile ?? "AGENTS.md";
setBundleDraft({
mode: "external",
rootPath: externalBundle?.rootPath ?? (bundle?.mode === "external" ? (bundle.rootPath ?? "") : ""),
entryFile: nextEntryFile,
});
setSelectedFile(externalBundle?.selectedFile ?? nextEntryFile);
}}
>
External
</Button>
</div>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
Root path
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
The absolute directory on disk where the instructions bundle lives. In managed mode this is set by Paperclip automatically.
</TooltipContent>
</Tooltip>
</span>
{currentMode === "managed" ? (
<div className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground">
<span className="min-w-0 break-all">{currentRootPath || "(managed)"}</span>
{currentRootPath && (
<CopyText text={currentRootPath} className="shrink-0">
<Copy className="h-3.5 w-3.5" />
</CopyText>
)}
</div>
) : (
<div className="flex items-center gap-1.5">
<Input
value={currentRootPath}
onChange={(event) => {
const nextRootPath = event.target.value;
externalBundleRef.current = {
rootPath: nextRootPath,
entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
setBundleDraft({
mode: "external",
rootPath: nextRootPath,
entryFile: currentEntryFile,
});
}}
className="font-mono text-sm"
placeholder="/absolute/path/to/agent/prompts"
/>
{currentRootPath && (
<CopyText text={currentRootPath} className="shrink-0">
<Copy className="h-3.5 w-3.5" />
</CopyText>
)}
</div>
)}
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
Entry file
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
The main file the agent reads first when loading instructions. Defaults to AGENTS.md.
</TooltipContent>
</Tooltip>
</span>
<Input
value={currentEntryFile}
onChange={(event) => {
const nextEntryFile = event.target.value || "AGENTS.md";
const nextSelectedFile = selectedOrEntryFile === currentEntryFile
? nextEntryFile
: selectedOrEntryFile;
if (currentMode === "external") {
externalBundleRef.current = {
rootPath: currentRootPath,
entryFile: nextEntryFile,
selectedFile: nextSelectedFile,
};
}
if (selectedOrEntryFile === currentEntryFile) setSelectedFile(nextEntryFile);
setBundleDraft({
mode: currentMode,
rootPath: currentRootPath,
entryFile: nextEntryFile,
});
}}
className="font-mono text-sm"
/>
</label>
</div>
</TooltipProvider>
</CollapsibleContent>
</Collapsible>
</div>
);
}
function PromptsTabSkeleton() {
return (
<div className="max-w-5xl space-y-4">
<div className="rounded-lg border border-border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-[30rem] max-w-full" />
</div>
<Skeleton className="h-4 w-16" />
</div>
<div className="grid gap-3 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-8 w-16" />
</div>
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-9 w-full rounded-none" />
))}
</div>
</div>
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-28" />
</div>
<PromptEditorSkeleton />
</div>
</div>
</div>
);
}
function PromptEditorSkeleton() {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-[420px] w-full" />
</div>
);
}
function AgentSkillsTab({
agent,
companyId,
}: {
agent: Agent;
companyId?: string;
}) {
type SkillRow = {
id: string;
key: string;
name: string;
description: string | null;
detail: string | null;
linkTo: string | null;
adapterEntry: AgentSkillSnapshot["entries"][number] | null;
};
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const lastSavedSkillsRef = useRef<string[]>([]);
const hasHydratedSkillSnapshotRef = useRef(false);
const skipNextSkillAutosaveRef = useRef(true);
const { data: skillSnapshot, isLoading } = useQuery({
queryKey: queryKeys.agents.skills(agent.id),
queryFn: () => agentsApi.skills(agent.id, companyId),
enabled: Boolean(companyId),
});
const { data: companySkills } = useQuery({
queryKey: queryKeys.companySkills.list(companyId ?? ""),
queryFn: () => companySkillsApi.list(companyId!),
enabled: Boolean(companyId),
});
const syncSkills = useMutation({
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
onSuccess: async (snapshot) => {
queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
lastSavedSkillsRef.current = snapshot.desiredSkills;
setLastSavedSkills(snapshot.desiredSkills);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
]);
},
});
useEffect(() => {
setSkillDraft([]);
setLastSavedSkills([]);
lastSavedSkillsRef.current = [];
hasHydratedSkillSnapshotRef.current = false;
skipNextSkillAutosaveRef.current = true;
}, [agent.id]);
useEffect(() => {
if (!skillSnapshot) return;
const nextState = applyAgentSkillSnapshot(
{
draft: skillDraft,
lastSaved: lastSavedSkillsRef.current,
hasHydratedSnapshot: hasHydratedSkillSnapshotRef.current,
},
skillSnapshot.desiredSkills,
);
skipNextSkillAutosaveRef.current = nextState.shouldSkipAutosave;
hasHydratedSkillSnapshotRef.current = nextState.hasHydratedSnapshot;
setSkillDraft(nextState.draft);
lastSavedSkillsRef.current = nextState.lastSaved;
setLastSavedSkills(nextState.lastSaved);
}, [skillDraft, skillSnapshot]);
useEffect(() => {
if (!skillSnapshot) return;
if (skipNextSkillAutosaveRef.current) {
skipNextSkillAutosaveRef.current = false;
return;
}
if (syncSkills.isPending) return;
if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return;
const timeout = window.setTimeout(() => {
if (!arraysEqual(skillDraft, lastSavedSkillsRef.current)) {
syncSkills.mutate(skillDraft);
}
}, 250);
return () => window.clearTimeout(timeout);
}, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
const companySkillByKey = useMemo(
() => new Map((companySkills ?? []).map((skill) => [skill.key, skill])),
[companySkills],
);
const adapterEntryByKey = useMemo(
() => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.key, entry])),
[skillSnapshot],
);
const optionalSkillRows = useMemo<SkillRow[]>(
() =>
(companySkills ?? [])
.filter((skill) => !adapterEntryByKey.get(skill.key)?.required)
.map((skill) => ({
id: skill.id,
key: skill.key,
name: skill.name,
description: skill.description,
detail: adapterEntryByKey.get(skill.key)?.detail ?? null,
linkTo: `/skills/${skill.id}`,
adapterEntry: adapterEntryByKey.get(skill.key) ?? null,
})),
[adapterEntryByKey, companySkills],
);
const requiredSkillRows = useMemo<SkillRow[]>(
() =>
(skillSnapshot?.entries ?? [])
.filter((entry) => entry.required)
.map((entry) => {
const companySkill = companySkillByKey.get(entry.key);
return {
id: companySkill?.id ?? `required:${entry.key}`,
key: entry.key,
name: companySkill?.name ?? entry.key,
description: companySkill?.description ?? null,
detail: entry.detail ?? null,
linkTo: companySkill ? `/skills/${companySkill.id}` : null,
adapterEntry: entry,
};
}),
[companySkillByKey, skillSnapshot],
);
const desiredOnlyMissingSkills = useMemo(
() => skillDraft.filter((key) => !companySkillByKey.has(key)),
[companySkillByKey, skillDraft],
);
const skillApplicationLabel = useMemo(() => {
switch (skillSnapshot?.mode) {
case "persistent":
return "Kept in the workspace";
case "ephemeral":
return "Applied when the agent runs";
case "unsupported":
return "Tracked only";
default:
return "Unknown";
}
}, [skillSnapshot?.mode]);
const unsupportedSkillMessage = useMemo(() => {
if (skillSnapshot?.mode !== "unsupported") return null;
if (agent.adapterType === "openclaw_gateway") {
return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
}
return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly.";
}, [agent.adapterType, skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending
? "Saving changes..."
: hasUnsavedChanges
? "Saving soon..."
: null;
return (
<div className="max-w-4xl space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link
to="/skills"
className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline"
>
View company skills library
</Link>
{saveStatusLabel ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{saveStatusLabel}</span>
</div>
) : null}
</div>
{skillSnapshot?.warnings.length ? (
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
{skillSnapshot.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
) : null}
{unsupportedSkillMessage ? (
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
{unsupportedSkillMessage}
</div>
) : null}
{isLoading ? (
<PageSkeleton variant="list" />
) : (
<>
{(() => {
const renderSkillRow = (skill: SkillRow) => {
const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key);
const required = Boolean(adapterEntry?.required);
const checked = required || skillDraft.includes(skill.key);
const disabled = required || skillSnapshot?.mode === "unsupported";
const checkbox = (
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.key]))
: skillDraft.filter((value) => value !== skill.key);
setSkillDraft(next);
}}
className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60"
/>
);
return (
<label
key={skill.id}
className="flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0 hover:bg-accent/20"
>
{required && adapterEntry?.requiredReason ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="top">{adapterEntry.requiredReason}</TooltipContent>
</Tooltip>
) : skillSnapshot?.mode === "unsupported" ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="top">
{unsupportedSkillMessage ?? "Manage skills in the adapter directly."}
</TooltipContent>
</Tooltip>
) : (
checkbox
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="truncate font-medium">{skill.name}</span>
</div>
{skill.linkTo ? (
<Link
to={skill.linkTo}
className="shrink-0 text-xs text-muted-foreground no-underline hover:text-foreground"
>
View
</Link>
) : null}
</div>
{skill.description && (
<MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{skill.description}
</MarkdownBody>
)}
{skill.detail && (
<p className="mt-1 text-xs text-muted-foreground">{skill.detail}</p>
)}
</div>
</label>
);
};
if (optionalSkillRows.length === 0 && requiredSkillRows.length === 0) {
return (
<section className="border-y border-border">
<div className="px-3 py-6 text-sm text-muted-foreground">
Import skills into the company library first, then attach them here.
</div>
</section>
);
}
return (
<>
{optionalSkillRows.length > 0 && (
<section className="border-y border-border">
{optionalSkillRows.map(renderSkillRow)}
</section>
)}
{requiredSkillRows.length > 0 && (
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
Required by Paperclip
</span>
</div>
{requiredSkillRows.map(renderSkillRow)}
</section>
)}
</>
);
})()}
{desiredOnlyMissingSkills.length > 0 && (
<div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
<div className="font-medium">Requested skills missing from the company library</div>
<div className="mt-1 text-xs">
{desiredOnlyMissingSkills.join(", ")}
</div>
</div>
)}
<section className="border-t border-border pt-4">
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Adapter</span>
<span className="font-medium">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Skills applied</span>
<span>{skillApplicationLabel}</span>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Selected skills</span>
<span>{skillDraft.length}</span>
</div>
</div>
{syncSkills.isError && (
<p className="mt-3 text-xs text-destructive">
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
</p>
)}
</section>
</>
)}
</div>
);
}
/* ---- Runs Tab ---- */
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon;
const metrics = runMetrics(run);
const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
return (
<Link
to={isSelected ? `/agents/${agentId}/runs` : `/agents/${agentId}/runs/${run.id}`}
className={cn(
"flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors no-underline text-inherit",
isSelected ? "bg-accent/40" : "hover:bg-accent/20",
)}
>
<div className="flex items-center gap-2">
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
<span className="font-mono text-xs text-muted-foreground">
{run.id.slice(0, 8)}
</span>
<span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
: "bg-muted text-muted-foreground"
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
{relativeTime(run.createdAt)}
</span>
</div>
{summary && (
<span className="text-xs text-muted-foreground truncate pl-5.5">
{summary.slice(0, 60)}
</span>
)}
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
<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>
)}
</Link>
);
}
function RunsTab({
runs,
companyId,
agentId,
agentRouteId,
selectedRunId,
adapterType,
}: {
runs: HeartbeatRun[];
companyId: string;
agentId: string;
agentRouteId: string;
selectedRunId: string | null;
adapterType: string;
}) {
const { isMobile } = useSidebar();
if (runs.length === 0) {
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
}
// Sort by created descending
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// On mobile, don't auto-select so the list shows first; on desktop, auto-select latest
const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null);
const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null;
// Mobile: show either run list OR run detail with back button
if (isMobile) {
if (selectedRun) {
return (
<div className="space-y-3 min-w-0 overflow-x-hidden">
<Link
to={`/agents/${agentRouteId}/runs`}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to runs
</Link>
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
</div>
);
}
return (
<div className="border border-border rounded-lg overflow-x-hidden">
{sorted.map((run) => (
<RunListItem key={run.id} run={run} isSelected={false} agentId={agentRouteId} />
))}
</div>
);
}
// Desktop: side-by-side layout
return (
<div className="flex gap-0">
{/* Left: run list — border stretches full height, content sticks */}
<div className={cn(
"shrink-0 border border-border rounded-lg",
selectedRun ? "w-72" : "w-full",
)}>
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
{sorted.map((run) => (
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentRouteId} />
))}
</div>
</div>
{/* Right: run detail — natural height, page scrolls */}
{selectedRun && (
<div className="flex-1 min-w-0 pl-4">
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
</div>
)}
</div>
);
}
/* ---- Run Detail (expanded) ---- */
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);
useEffect(() => {
setClaudeLoginResult(null);
}, [run.id]);
const cancelRun = useMutation({
mutationFn: () => heartbeatsApi.cancel(run.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
},
});
const canResumeLostRun = run.errorCode === "process_lost" && run.status === "failed";
const resumePayload = useMemo(() => {
const payload: Record<string, unknown> = {
resumeFromRunId: run.id,
};
const context = asRecord(run.contextSnapshot);
if (!context) return payload;
const issueId = asNonEmptyString(context.issueId);
const taskId = asNonEmptyString(context.taskId);
const taskKey = asNonEmptyString(context.taskKey);
const commentId = asNonEmptyString(context.wakeCommentId) ?? asNonEmptyString(context.commentId);
if (issueId) payload.issueId = issueId;
if (taskId) payload.taskId = taskId;
if (taskKey) payload.taskKey = taskKey;
if (commentId) payload.commentId = commentId;
return payload;
}, [run.contextSnapshot, run.id]);
const resumeRun = useMutation({
mutationFn: async () => {
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "resume_process_lost_run",
payload: resumePayload,
}, run.companyId);
if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable.");
}
return result;
},
onSuccess: (resumedRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`);
},
});
const canRetryRun = run.status === "failed" || run.status === "timed_out";
const retryPayload = useMemo(() => {
const payload: Record<string, unknown> = {};
const context = asRecord(run.contextSnapshot);
if (!context) return payload;
const issueId = asNonEmptyString(context.issueId);
const taskId = asNonEmptyString(context.taskId);
const taskKey = asNonEmptyString(context.taskKey);
if (issueId) payload.issueId = issueId;
if (taskId) payload.taskId = taskId;
if (taskKey) payload.taskKey = taskKey;
return payload;
}, [run.contextSnapshot]);
const retryRun = useMutation({
mutationFn: async () => {
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload: retryPayload,
}, run.companyId);
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return result;
},
onSuccess: (newRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
navigate(`/agents/${agentRouteId}/runs/${newRun.id}`);
},
});
const { data: touchedIssues } = useQuery({
queryKey: queryKeys.runIssues(run.id),
queryFn: () => activityApi.issuesForRun(run.id),
});
const touchedIssueIds = useMemo(
() => Array.from(new Set((touchedIssues ?? []).map((issue) => issue.issueId))),
[touchedIssues],
);
const clearSessionsForTouchedIssues = useMutation({
mutationFn: async () => {
if (touchedIssueIds.length === 0) return 0;
await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId, run.companyId)));
return touchedIssueIds.length;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) });
},
});
const runClaudeLogin = useMutation({
mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
});
const isRunning = run.status === "running" && !!run.startedAt && !run.finishedAt;
const [elapsedSec, setElapsedSec] = useState<number>(() => {
if (!run.startedAt) return 0;
return Math.max(0, Math.round((Date.now() - new Date(run.startedAt).getTime()) / 1000));
});
useEffect(() => {
if (!isRunning || !run.startedAt) return;
const startMs = new Date(run.startedAt).getTime();
setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000)));
const id = setInterval(() => {
setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000)));
}, 1000);
return () => clearInterval(id);
}, [isRunning, run.startedAt]);
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null;
const durationSec = run.startedAt && run.finishedAt
? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)
: null;
const displayDurationSec = durationSec ?? (isRunning ? elapsedSec : null);
const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0;
const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter);
const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter;
const sessionId = run.sessionIdAfter || run.sessionIdBefore;
const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0;
return (
<div className="space-y-4 min-w-0">
{/* Run summary card */}
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex flex-col sm:flex-row">
{/* Left column: status + timing */}
<div className="flex-1 p-4 space-y-3">
<div className="flex items-center gap-2">
<StatusBadge status={run.status} />
{(run.status === "running" || run.status === "queued") && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs h-6 px-2"
onClick={() => cancelRun.mutate()}
disabled={cancelRun.isPending}
>
{cancelRun.isPending ? "Cancelling…" : "Cancel"}
</Button>
)}
{canResumeLostRun && (
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => resumeRun.mutate()}
disabled={resumeRun.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
{resumeRun.isPending ? "Resuming…" : "Resume"}
</Button>
)}
{canRetryRun && !canResumeLostRun && (
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => retryRun.mutate()}
disabled={retryRun.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
{retryRun.isPending ? "Retrying…" : "Retry"}
</Button>
)}
</div>
{resumeRun.isError && (
<div className="text-xs text-destructive">
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
</div>
)}
{retryRun.isError && (
<div className="text-xs text-destructive">
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
</div>
)}
{startTime && (
<div className="space-y-0.5">
<div className="text-sm font-mono">
{startTime}
{endTime && <span className="text-muted-foreground"> &rarr; </span>}
{endTime}
</div>
<div className="text-[11px] text-muted-foreground">
{relativeTime(run.startedAt!)}
{run.finishedAt && <> &rarr; {relativeTime(run.finishedAt)}</>}
</div>
{displayDurationSec !== null && (
<div className="text-xs text-muted-foreground">
Duration: {displayDurationSec >= 60 ? `${Math.floor(displayDurationSec / 60)}m ${displayDurationSec % 60}s` : `${displayDurationSec}s`}
</div>
)}
</div>
)}
{run.error && (
<div className="text-xs">
<span className="text-red-600 dark:text-red-400">{run.error}</span>
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
</div>
)}
{run.errorCode === "claude_auth_required" && adapterType === "claude_local" && (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => runClaudeLogin.mutate()}
disabled={runClaudeLogin.isPending}
>
{runClaudeLogin.isPending ? "Running claude login..." : "Login to Claude Code"}
</Button>
{runClaudeLogin.isError && (
<p className="text-xs text-destructive">
{runClaudeLogin.error instanceof Error
? runClaudeLogin.error.message
: "Failed to run Claude login"}
</p>
)}
{claudeLoginResult?.loginUrl && (
<p className="text-xs">
Login URL:
<a
href={claudeLoginResult.loginUrl}
className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{claudeLoginResult.loginUrl}
</a>
</p>
)}
{claudeLoginResult && (
<>
{!!claudeLoginResult.stdout && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stdout}
</pre>
)}
{!!claudeLoginResult.stderr && (
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{claudeLoginResult.stderr}
</pre>
)}
</>
)}
</div>
)}
{hasNonZeroExit && (
<div className="text-xs text-red-600 dark:text-red-400">
Exit code {run.exitCode}
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
</div>
)}
</div>
{/* 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 tabular-nums">
<div>
<div className="text-xs text-muted-foreground">Input</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Output</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.output)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Cached</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.cached)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Cost</div>
<div className="text-sm font-medium font-mono">{metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"}</div>
</div>
</div>
)}
</div>
{/* Collapsible session row */}
{hasSession && (
<div className="border-t border-border">
<button
className="flex items-center gap-1.5 w-full px-4 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSessionOpen((v) => !v)}
>
<ChevronRight className={cn("h-3 w-3 transition-transform", sessionOpen && "rotate-90")} />
Session
{sessionChanged && <span className="text-yellow-400 ml-1">(changed)</span>}
</button>
{sessionOpen && (
<div className="px-4 pb-3 space-y-1 text-xs">
{run.sessionIdBefore && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-12">{sessionChanged ? "Before" : "ID"}</span>
<CopyText text={run.sessionIdBefore} className="font-mono" />
</div>
)}
{sessionChanged && run.sessionIdAfter && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-12">After</span>
<CopyText text={run.sessionIdAfter} className="font-mono" />
</div>
)}
{touchedIssueIds.length > 0 && (
<div className="pt-1">
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
disabled={clearSessionsForTouchedIssues.isPending}
onClick={() => {
const issueCount = touchedIssueIds.length;
const confirmed = window.confirm(
`Clear session for ${issueCount} issue${issueCount === 1 ? "" : "s"} touched by this run?`,
);
if (!confirmed) return;
clearSessionsForTouchedIssues.mutate();
}}
>
{clearSessionsForTouchedIssues.isPending
? "clearing session..."
: "clear session for these issues"}
</button>
{clearSessionsForTouchedIssues.isError && (
<p className="text-[11px] text-destructive mt-1">
{clearSessionsForTouchedIssues.error instanceof Error
? clearSessionsForTouchedIssues.error.message
: "Failed to clear sessions"}
</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Issues touched by this run */}
{touchedIssues && touchedIssues.length > 0 && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
<div className="border border-border rounded-lg divide-y divide-border">
{touchedIssues.map((issue) => (
<Link
key={issue.issueId}
to={`/issues/${issue.identifier ?? issue.issueId}`}
className="flex items-center justify-between w-full px-3 py-2 text-xs hover:bg-accent/20 transition-colors text-left no-underline text-inherit"
>
<div className="flex items-center gap-2 min-w-0">
<StatusBadge status={issue.status} />
<span className="truncate">{issue.title}</span>
</div>
<span className="font-mono text-muted-foreground shrink-0 ml-2">{issue.identifier ?? issue.issueId.slice(0, 8)}</span>
</Link>
))}
</div>
</div>
)}
{/* stderr excerpt for failed runs */}
{run.stderrExcerpt && (
<div className="space-y-1">
<span className="text-xs font-medium text-red-600 dark:text-red-400">stderr</span>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
</div>
)}
{/* stdout excerpt when no log is available */}
{run.stdoutExcerpt && !run.logRef && (
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">stdout</span>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
</div>
)}
{/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom />
</div>
);
}
/* ---- Log Viewer ---- */
function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
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);
const isFollowingRef = useRef(false);
const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({
scrollHeight: 0,
distanceFromBottom: Number.POSITIVE_INFINITY,
});
const isLive = run.status === "running" || run.status === "queued";
const { data: workspaceOperations = [] } = useQuery({
queryKey: queryKeys.runWorkspaceOperations(run.id),
queryFn: () => heartbeatsApi.workspaceOperations(run.id),
refetchInterval: isLive ? 2000 : false,
});
function isRunLogUnavailable(err: unknown): boolean {
return err instanceof ApiError && err.status === 404;
}
function appendLogContent(content: string, finalize = false) {
if (!content && !finalize) return;
const combined = `${pendingLogLineRef.current}${content}`;
const split = combined.split("\n");
pendingLogLineRef.current = split.pop() ?? "";
if (finalize && pendingLogLineRef.current) {
split.push(pendingLogLineRef.current);
pendingLogLineRef.current = "";
}
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const stream =
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
} catch {
// ignore malformed lines
}
}
if (parsed.length > 0) {
setLogLines((prev) => [...prev, ...parsed]);
}
}
// Fetch events
const { data: initialEvents } = useQuery({
queryKey: ["run-events", run.id],
queryFn: () => heartbeatsApi.events(run.id, 0, 200),
});
useEffect(() => {
if (initialEvents) {
setEvents(initialEvents);
setLoading(false);
}
}, [initialEvents]);
const getScrollContainer = useCallback((): ScrollContainer => {
if (scrollContainerRef.current) return scrollContainerRef.current;
const container = findScrollContainer(logEndRef.current);
scrollContainerRef.current = container;
return container;
}, []);
const updateFollowingState = useCallback(() => {
const container = getScrollContainer();
const metrics = readScrollMetrics(container);
lastMetricsRef.current = metrics;
const nearBottom = metrics.distanceFromBottom <= LIVE_SCROLL_BOTTOM_TOLERANCE_PX;
isFollowingRef.current = nearBottom;
setIsFollowing((prev) => (prev === nearBottom ? prev : nearBottom));
}, [getScrollContainer]);
useEffect(() => {
scrollContainerRef.current = null;
lastMetricsRef.current = {
scrollHeight: 0,
distanceFromBottom: Number.POSITIVE_INFINITY,
};
if (!isLive) {
isFollowingRef.current = false;
setIsFollowing(false);
return;
}
updateFollowingState();
}, [isLive, run.id, updateFollowingState]);
useEffect(() => {
if (!isLive) return;
const container = getScrollContainer();
updateFollowingState();
if (container === window) {
window.addEventListener("scroll", updateFollowingState, { passive: true });
} else {
container.addEventListener("scroll", updateFollowingState, { passive: true });
}
window.addEventListener("resize", updateFollowingState);
return () => {
if (container === window) {
window.removeEventListener("scroll", updateFollowingState);
} else {
container.removeEventListener("scroll", updateFollowingState);
}
window.removeEventListener("resize", updateFollowingState);
};
}, [isLive, run.id, getScrollContainer, updateFollowingState]);
// Auto-scroll only for live runs when following
useEffect(() => {
if (!isLive || !isFollowingRef.current) return;
const container = getScrollContainer();
const previous = lastMetricsRef.current;
const current = readScrollMetrics(container);
const growth = Math.max(0, current.scrollHeight - previous.scrollHeight);
const expectedDistance = previous.distanceFromBottom + growth;
const movedAwayBy = current.distanceFromBottom - expectedDistance;
// If user moved away from bottom between updates, release auto-follow immediately.
if (movedAwayBy > LIVE_SCROLL_BOTTOM_TOLERANCE_PX) {
isFollowingRef.current = false;
setIsFollowing(false);
lastMetricsRef.current = current;
return;
}
scrollToContainerBottom(container, "auto");
const after = readScrollMetrics(container);
lastMetricsRef.current = after;
if (!isFollowingRef.current) {
isFollowingRef.current = true;
}
setIsFollowing((prev) => (prev ? prev : true));
}, [events.length, logLines.length, isLive, getScrollContainer]);
// Fetch persisted shell log
useEffect(() => {
let cancelled = false;
pendingLogLineRef.current = "";
setLogLines([]);
setLogOffset(0);
setLogError(null);
if (!run.logRef && !isLive) {
setLogLoading(false);
return () => {
cancelled = true;
};
}
setLogLoading(true);
const firstLimit =
typeof run.logBytes === "number" && run.logBytes > 0
? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
: 256_000;
const load = async () => {
try {
let offset = 0;
let first = true;
while (!cancelled) {
const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
if (cancelled) break;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? offset + result.content.length;
setLogOffset(next);
offset = next;
first = false;
if (result.nextOffset === undefined || isLive) break;
}
} catch (err) {
if (!cancelled) {
if (isLive && isRunLogUnavailable(err)) {
setLogLoading(false);
return;
}
setLogError(err instanceof Error ? err.message : "Failed to load run log");
}
} finally {
if (!cancelled) setLogLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [run.id, run.logRef, run.logBytes, isLive]);
// Poll for live updates
useEffect(() => {
if (!isLive || isStreamingConnected) return;
const interval = setInterval(async () => {
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
try {
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
if (newEvents.length > 0) {
setEvents((prev) => [...prev, ...newEvents]);
}
} catch {
// ignore polling errors
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, isLive, isStreamingConnected, events]);
// Poll shell log for running runs
useEffect(() => {
if (!isLive || isStreamingConnected) return;
const interval = setInterval(async () => {
try {
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
if (result.content) {
appendLogContent(result.content, result.nextOffset === undefined);
}
if (result.nextOffset !== undefined) {
setLogOffset(result.nextOffset);
} else if (result.content.length > 0) {
setLogOffset((prev) => prev + result.content.length);
}
} catch (err) {
if (isRunLogUnavailable(err)) return;
// ignore polling errors
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, isLive, isStreamingConnected, logOffset]);
// Stream live updates from websocket (primary path for running runs).
useEffect(() => {
if (!isLive) return;
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const scheduleReconnect = () => {
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1500);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
socket = new WebSocket(url);
socket.onopen = () => {
setIsStreamingConnected(true);
};
socket.onmessage = (message) => {
const rawMessage = typeof message.data === "string" ? message.data : "";
if (!rawMessage) return;
let event: LiveEvent;
try {
event = JSON.parse(rawMessage) as LiveEvent;
} catch {
return;
}
if (event.companyId !== run.companyId) return;
const payload = asRecord(event.payload);
const eventRunId = asNonEmptyString(payload?.runId);
if (!payload || eventRunId !== run.id) return;
if (event.type === "heartbeat.run.log") {
const chunk = typeof payload.chunk === "string" ? payload.chunk : "";
if (!chunk) return;
const streamRaw = asNonEmptyString(payload.stream);
const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
setLogLines((prev) => [...prev, { ts, stream, chunk }]);
return;
}
if (event.type !== "heartbeat.run.event") return;
const seq = typeof payload.seq === "number" ? payload.seq : null;
if (seq === null || !Number.isFinite(seq)) return;
const streamRaw = asNonEmptyString(payload.stream);
const stream =
streamRaw === "stdout" || streamRaw === "stderr" || streamRaw === "system"
? streamRaw
: null;
const levelRaw = asNonEmptyString(payload.level);
const level =
levelRaw === "info" || levelRaw === "warn" || levelRaw === "error"
? levelRaw
: null;
const liveEvent: HeartbeatRunEvent = {
id: seq,
companyId: run.companyId,
runId: run.id,
agentId: run.agentId,
seq,
eventType: asNonEmptyString(payload.eventType) ?? "event",
stream,
level,
color: asNonEmptyString(payload.color),
message: asNonEmptyString(payload.message),
payload: asRecord(payload.payload),
createdAt: new Date(event.createdAt),
};
setEvents((prev) => {
if (prev.some((existing) => existing.seq === seq)) return prev;
return [...prev, liveEvent];
});
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
setIsStreamingConnected(false);
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
setIsStreamingConnected(false);
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "run_detail_unmount");
}
};
}, [isLive, run.companyId, run.id, run.agentId]);
const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke");
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>;
}
if (events.length === 0 && logLines.length === 0 && !logError) {
return <p className="text-xs text-muted-foreground">No log events.</p>;
}
const levelColors: Record<string, string> = {
info: "text-foreground",
warn: "text-yellow-600 dark:text-yellow-400",
error: "text-red-600 dark:text-red-400",
};
const streamColors: Record<string, string> = {
stdout: "text-foreground",
stderr: "text-red-600 dark:text-red-300",
system: "text-blue-600 dark:text-blue-300",
};
return (
<div className="space-y-3">
<WorkspaceOperationsSection operations={workspaceOperations} />
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
{typeof adapterInvokePayload.adapterType === "string" && (
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
)}
{typeof adapterInvokePayload.cwd === "string" && (
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
)}
{typeof adapterInvokePayload.command === "string" && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">
{[
adapterInvokePayload.command,
...(Array.isArray(adapterInvokePayload.commandArgs)
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
: []),
].join(" ")}
</span>
</div>
)}
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{adapterInvokePayload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{adapterInvokePayload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<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"
? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.context !== undefined && (
<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(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env)}
</pre>
</div>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
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"
size="xs"
onClick={() => {
const container = getScrollContainer();
isFollowingRef.current = true;
setIsFollowing(true);
scrollToContainerBottom(container, "auto");
lastMetricsRef.current = readScrollMetrics(container);
}}
>
Jump to live
</Button>
)}
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<span className="relative flex h-2 w-2">
<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
</span>
)}
</div>
</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>
)}
<div ref={logEndRef} />
</div>
{(run.status === "failed" || run.status === "timed_out") && (
<div className="rounded-lg border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-950/20 p-3 space-y-2">
<div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
{run.error && (
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{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">
{redactHomePathUserSegments(run.stderrExcerpt)}
</pre>
</div>
)}
{run.resultJson && (
<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(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
</pre>
</div>
)}
{run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
<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">
{redactHomePathUserSegments(run.stdoutExcerpt)}
</pre>
</div>
)}
</div>
)}
{events.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
{events.map((evt) => {
const color = evt.color
?? (evt.level ? levelColors[evt.level] : null)
?? (evt.stream ? streamColors[evt.stream] : null)
?? "text-foreground";
return (
<div key={evt.id} className="flex gap-2">
<span className="text-neutral-400 dark:text-neutral-600 shrink-0 select-none w-16">
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
</span>
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
{evt.stream ? `[${evt.stream}]` : ""}
</span>
<span className={cn("break-all", color)}>
{evt.message
? redactHomePathUserSegments(evt.message)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
: ""}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
/* ---- Keys Tab ---- */
function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenVisible, setTokenVisible] = useState(false);
const [copied, setCopied] = useState(false);
const { data: keys, isLoading } = useQuery({
queryKey: queryKeys.agents.keys(agentId),
queryFn: () => agentsApi.listKeys(agentId, companyId),
});
const createKey = useMutation({
mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId),
onSuccess: (data) => {
setNewToken(data.token);
setTokenVisible(true);
setNewKeyName("");
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
},
});
const revokeKey = useMutation({
mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
},
});
function copyToken() {
if (!newToken) return;
navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const activeKeys = (keys ?? []).filter((k: AgentKey) => !k.revokedAt);
const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
return (
<div className="space-y-6">
{/* New token banner */}
{newToken && (
<div className="border border-yellow-300 dark:border-yellow-600/40 bg-yellow-50 dark:bg-yellow-500/5 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
API key created copy it now, it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-neutral-100 dark:bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-700 dark:text-green-300 truncate">
{tokenVisible ? newToken : newToken.replace(/./g, "•")}
</code>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setTokenVisible((v) => !v)}
title={tokenVisible ? "Hide" : "Show"}
>
{tokenVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={copyToken}
title="Copy"
>
<Copy className="h-3.5 w-3.5" />
</Button>
{copied && <span className="text-xs text-green-400">Copied!</span>}
</div>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground text-xs"
onClick={() => setNewToken(null)}
>
Dismiss
</Button>
</div>
)}
{/* Create new key */}
<div className="border border-border rounded-lg p-4 space-y-3">
<h3 className="text-xs font-medium text-muted-foreground flex items-center gap-2">
<Key className="h-3.5 w-3.5" />
Create API Key
</h3>
<p className="text-xs text-muted-foreground">
API keys allow this agent to authenticate calls to the Paperclip server.
</p>
<div className="flex items-center gap-2">
<Input
placeholder="Key name (e.g. production)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") createKey.mutate();
}}
/>
<Button
size="sm"
onClick={() => createKey.mutate()}
disabled={createKey.isPending}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Create
</Button>
</div>
</div>
{/* Active keys */}
{isLoading && <p className="text-sm text-muted-foreground">Loading keys...</p>}
{!isLoading && activeKeys.length === 0 && !newToken && (
<p className="text-sm text-muted-foreground">No active API keys.</p>
)}
{activeKeys.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Active Keys
</h3>
<div className="border border-border rounded-lg divide-y divide-border">
{activeKeys.map((key: AgentKey) => (
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<span className="text-sm font-medium">{key.name}</span>
<span className="text-xs text-muted-foreground ml-3">
Created {formatDate(key.createdAt)}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs"
onClick={() => revokeKey.mutate(key.id)}
disabled={revokeKey.isPending}
>
Revoke
</Button>
</div>
))}
</div>
</div>
)}
{/* Revoked keys */}
{revokedKeys.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Revoked Keys
</h3>
<div className="border border-border rounded-lg divide-y divide-border opacity-50">
{revokedKeys.map((key: AgentKey) => (
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<span className="text-sm line-through">{key.name}</span>
<span className="text-xs text-muted-foreground ml-3">
Revoked {key.revokedAt ? formatDate(key.revokedAt) : ""}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}