mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
Keep alias caches and redirects on the agent company. Defer remembered UUID paths until canonical resolution and verify organization switching with a delayed public alias response. Co-Authored-By: Paperclip <noreply@paperclip.ing>
4821 lines
201 KiB
TypeScript
4821 lines
201 KiB
TypeScript
import { PrimaryAgentIndicator, SetPrimaryAgentButton } from "@/components/primary-agent/PrimaryAgentPresentation";
|
||
import { AiConnectionPoolRunDetails } from "@/components/ai-connections/AiConnectionPoolRunDetails";
|
||
import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions";
|
||
import type { AgentInstructionCandidate, AgentInstructionsBundle } from "@paperclipai/shared";
|
||
import { InstructionHistory } from "../components/InstructionHistory";
|
||
import { AgentCharacter } from "../components/AgentCharacter";
|
||
import { characterStateForAgent } from "@paperclipai/shared";
|
||
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
|
||
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
|
||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
|
||
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||
import {
|
||
agentsApi,
|
||
type AgentKey,
|
||
type ClaudeLoginResult,
|
||
type AgentPermissionUpdate,
|
||
} from "../api/agents";
|
||
import { builtInAgentsApi, type BuiltInManagedResourceKind } from "../api/builtInAgents";
|
||
import { companySkillsApi } from "../api/companySkills";
|
||
import { heartbeatsApi } from "../api/heartbeats";
|
||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||
import { ApiError } from "../api/client";
|
||
import { activityApi } from "../api/activity";
|
||
import { accessApi } from "../api/access";
|
||
import { issuesApi } from "../api/issues";
|
||
import { projectsApi } from "../api/projects";
|
||
import { usePanel } from "../context/PanelContext";
|
||
import { useSidebar } from "../context/SidebarContext";
|
||
import { useCompany } from "../context/CompanyContext";
|
||
import { useToastActions } from "../context/ToastContext";
|
||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||
import { queryKeys } from "../lib/queryKeys";
|
||
import { applyCompanyPrefix } from "../lib/company-routes";
|
||
import { copyTextToClipboard } from "../lib/clipboard";
|
||
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
|
||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
|
||
import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils";
|
||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||
import { assetsApi } from "../api/assets";
|
||
import { toolsApi } from "../api/tools";
|
||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||
import { StatusBadge } from "../components/StatusBadge";
|
||
import { MarkdownBody } from "../components/MarkdownBody";
|
||
import { CopyText } from "../components/CopyText";
|
||
import { IssueRow } from "../components/IssueRow";
|
||
import { StatusGlyph } from "../components/StatusGlyph";
|
||
import { MembershipAction } from "../components/MembershipAction";
|
||
import { StarToggle } from "../components/StarToggle";
|
||
import { Identity } from "../components/Identity";
|
||
import { PageSkeleton } from "../components/PageSkeleton";
|
||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||
import { InlineBanner } from "../components/InlineBanner";
|
||
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
|
||
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
|
||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||
import { FileTree, buildFileTree } from "../components/FileTree";
|
||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
|
||
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
|
||
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
|
||
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
||
import { tryCreateWebSocket } from "../lib/websocket";
|
||
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal } from "../lib/utils";
|
||
import { cn } from "../lib/utils";
|
||
import { RunRetryDetails } from "../components/RunRetryDetails";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Tabs } from "@/components/ui/tabs";
|
||
import { PageTabBar } from "../components/PageTabBar";
|
||
import { AuditFeed } from "./audit/AuditFeed";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||
import {
|
||
CheckCircle2,
|
||
XCircle,
|
||
Clock,
|
||
Timer,
|
||
Loader2,
|
||
Slash,
|
||
RotateCcw,
|
||
Plus,
|
||
Key,
|
||
Eye,
|
||
EyeOff,
|
||
Copy,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
ArrowLeft,
|
||
HelpCircle,
|
||
FolderOpen,
|
||
AlertTriangle,
|
||
} from "lucide-react";
|
||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||
import { Input } from "@/components/ui/input";
|
||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||
import { AgentToolsTab } from "./AgentToolsTab";
|
||
import { AgentChannelsPanel } from "../components/chat/AgentChannelsPanel";
|
||
import {
|
||
appendCapped,
|
||
LIVE_TRANSCRIPT_RENDER_LIMIT,
|
||
MAX_LIVE_EVENTS,
|
||
MAX_LIVE_LOG_LINES,
|
||
} from "../lib/live-log-buffer";
|
||
import {
|
||
isUuidLike,
|
||
type Agent,
|
||
type AgentDetail as AgentDetailRecord,
|
||
type HeartbeatRun,
|
||
type HeartbeatRunEvent,
|
||
type AgentRuntimeState,
|
||
type Issue,
|
||
type LiveEvent,
|
||
type WorkspaceOperation,
|
||
isResponsibleUserDenialCode,
|
||
responsibleUserLabel,
|
||
} from "@paperclipai/shared";
|
||
import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice";
|
||
import { RunWorkspaceRecoverySurface } from "../components/RunWorkspaceRecoverySurface";
|
||
import { RunnerInspector } from "../components/RunnerInspector";
|
||
import { HoneycombRunLink } from "../components/HoneycombRunLink";
|
||
import {
|
||
ProviderTraceStatusBadge,
|
||
runRequestedProviderTrace,
|
||
} from "../components/ProviderTraceStatusBadge";
|
||
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
|
||
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
||
import { agentRouteRef } from "../lib/utils";
|
||
import {
|
||
isStarred,
|
||
resourceMembershipState,
|
||
useResourceMembershipMutation,
|
||
useResourceMemberships,
|
||
} from "../hooks/useResourceMemberships";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
AGENT_DETAIL_NAVIGATION,
|
||
agentDetailHref,
|
||
agentLegacyAuditSection,
|
||
agentScopedAuditHref,
|
||
parseAgentDetailView,
|
||
type AgentDetailView,
|
||
} from "./agent-detail-navigation";
|
||
|
||
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-blue-600 dark:text-blue-400" }, // Gallery feedback r1: running = status blue, not cyan.
|
||
queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
|
||
scheduled_retry: { icon: Clock, color: "text-sky-600 dark:text-sky-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 RUN_LOG_PAGE_BYTES = 256_000;
|
||
|
||
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 COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
||
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
||
|
||
function formatOrgChainHealthPath(agent: AgentDetailRecord) {
|
||
return agent.orgChainHealth?.fullChain
|
||
.map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`)
|
||
.join(" -> ") ?? agent.name;
|
||
}
|
||
|
||
function redactPathText(value: string, censorUsernameInLogs: boolean) {
|
||
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
|
||
}
|
||
|
||
function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
|
||
return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
|
||
}
|
||
|
||
function redactCommandText(value: string, censorUsernameInLogs: boolean): string {
|
||
return redactPathText(redactCommandSecretText(value, REDACTED_ENV_VALUE), censorUsernameInLogs);
|
||
}
|
||
|
||
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, censorUsernameInLogs: boolean): 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" && COMMAND_ENV_KEY_RE.test(key)) return redactCommandText(value, censorUsernameInLogs);
|
||
if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
|
||
try {
|
||
return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
|
||
} catch {
|
||
return redactPathText(String(value), censorUsernameInLogs);
|
||
}
|
||
}
|
||
|
||
function isMarkdown(pathValue: string) {
|
||
return pathValue.toLowerCase().endsWith(".md");
|
||
}
|
||
|
||
function shouldUseMarkdownInstructionsEditor(input: {
|
||
selectedFileExists: boolean;
|
||
selectedPath: string;
|
||
detail?: { markdown?: boolean } | null;
|
||
summary?: { markdown?: boolean } | null;
|
||
}) {
|
||
const metadataMarkdown = input.detail?.markdown ?? input.summary?.markdown;
|
||
if (typeof metadataMarkdown === "boolean") return metadataMarkdown;
|
||
return isMarkdown(input.selectedPath);
|
||
}
|
||
|
||
function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): 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], censorUsernameInLogs)}`)
|
||
.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 });
|
||
}
|
||
|
||
/** @deprecated Use AGENT_DETAIL_NAVIGATION for contextual navigation. */
|
||
export const AGENT_DETAIL_TABS = AGENT_DETAIL_NAVIGATION.flatMap((section) => section.items);
|
||
|
||
const LEGACY_AGENT_DETAIL_TABS = [
|
||
{ value: "dashboard", label: "Dashboard" },
|
||
{ value: "instructions", label: "Instructions" },
|
||
{ value: "skills", label: "Skills" },
|
||
{ value: "configuration", label: "Configuration" },
|
||
{ value: "secrets", label: "Secrets" },
|
||
{ value: "tools", label: "Tools" },
|
||
{ value: "runs", label: "Runs" },
|
||
{ value: "audit", label: "Audit" },
|
||
{ value: "budget", label: "Budget" },
|
||
] as const;
|
||
|
||
export const DISCARD_AGENT_CONFIG_CHANGES_MESSAGE = "Discard unsaved agent configuration changes?";
|
||
|
||
export function confirmAgentConfigNavigation(
|
||
dirty: boolean,
|
||
confirm: (message: string) => boolean = (message) =>
|
||
typeof window === "undefined" || window.confirm(message),
|
||
): boolean {
|
||
return !dirty || confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE);
|
||
}
|
||
|
||
export function agentConfigHistoryRestoreDelta(currentIndex: unknown, nextIndex: unknown): number | null {
|
||
if (typeof currentIndex !== "number" || typeof nextIndex !== "number") return null;
|
||
const delta = currentIndex - nextIndex;
|
||
return delta === 0 ? null : delta;
|
||
}
|
||
|
||
export function restoreAgentConfigHistoryEntry(
|
||
history: Pick<History, "go" | "pushState">,
|
||
currentEntry: { index: unknown; state: unknown; url: string },
|
||
nextIndex: unknown,
|
||
): boolean {
|
||
const restoreDelta = agentConfigHistoryRestoreDelta(currentEntry.index, nextIndex);
|
||
if (restoreDelta === null) {
|
||
// Some legacy URL-cleanup paths erased React Router's history index. A
|
||
// fresh copy of the guarded entry is the only safe way to return without
|
||
// letting Router consume the unindexed destination and discard the form.
|
||
history.pushState(currentEntry.state, "", currentEntry.url);
|
||
return false;
|
||
}
|
||
|
||
history.go(restoreDelta);
|
||
return true;
|
||
}
|
||
|
||
export { agentDetailHref, agentScopedAuditHref, parseAgentDetailView };
|
||
|
||
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);
|
||
const provider = asNonEmptyString(usage?.provider) ?? null;
|
||
const model = asNonEmptyString(usage?.model) ?? null;
|
||
return {
|
||
input,
|
||
output,
|
||
cached,
|
||
cost,
|
||
totalTokens: visibleRunTokenTotal(usage),
|
||
provider,
|
||
model,
|
||
};
|
||
}
|
||
|
||
export type RunLogChunk = {
|
||
seq?: number;
|
||
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;
|
||
}
|
||
|
||
export function buildHeartbeatProgressLogLine(
|
||
payload: Record<string, unknown>,
|
||
fallbackTimestamp: string,
|
||
): RunLogChunk | null {
|
||
const message = asNonEmptyString(payload.message);
|
||
if (!message) return null;
|
||
const phase = asNonEmptyString(payload.phase);
|
||
const ts = asNonEmptyString(payload.updatedAt) ?? fallbackTimestamp;
|
||
const chunk = phase ? `[${phase}] ${message}` : message;
|
||
return { ts, stream: "system", chunk };
|
||
}
|
||
|
||
export function heartbeatProgressLogLineKey(line: RunLogChunk): string {
|
||
return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`;
|
||
}
|
||
|
||
export function shouldPollRunShellLog(status: HeartbeatRun["status"]): boolean {
|
||
return status === "running";
|
||
}
|
||
|
||
export function runDetailRefetchIntervalMs(status: HeartbeatRun["status"]): 5000 | 15000 | false {
|
||
if (status === "queued") return 5000;
|
||
if (status === "running") return 15000;
|
||
return false;
|
||
}
|
||
|
||
export function RunInvocationCard({
|
||
payload,
|
||
censorUsernameInLogs,
|
||
}: {
|
||
payload: Record<string, unknown>;
|
||
censorUsernameInLogs: boolean;
|
||
}) {
|
||
const rawCommandLine = [
|
||
typeof payload.command === "string" ? payload.command : null,
|
||
...(Array.isArray(payload.commandArgs)
|
||
? payload.commandArgs.filter((value): value is string => typeof value === "string")
|
||
: []),
|
||
]
|
||
.filter((value): value is string => Boolean(value))
|
||
.join(" ");
|
||
const commandLine = rawCommandLine ? redactCommandText(rawCommandLine, censorUsernameInLogs) : "";
|
||
|
||
const hasAdvancedDetails =
|
||
commandLine.length > 0
|
||
|| (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0)
|
||
|| payload.prompt !== undefined
|
||
|| payload.context !== undefined
|
||
|| payload.env !== undefined;
|
||
|
||
return (
|
||
<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 payload.adapterType === "string" && (
|
||
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{payload.adapterType}</div>
|
||
)}
|
||
{typeof payload.cwd === "string" && (
|
||
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{payload.cwd}</span></div>
|
||
)}
|
||
{hasAdvancedDetails && (
|
||
<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" />
|
||
Details
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="pt-2 space-y-2">
|
||
{commandLine && (
|
||
<div className="text-xs break-all">
|
||
<span className="text-muted-foreground">Command: </span>
|
||
<span className="font-mono">{commandLine}</span>
|
||
</div>
|
||
)}
|
||
{Array.isArray(payload.commandNotes) && payload.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">
|
||
{payload.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>
|
||
)}
|
||
{payload.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 payload.prompt === "string"
|
||
? redactPathText(payload.prompt, censorUsernameInLogs)
|
||
: JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{payload.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(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{payload.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(payload.env, censorUsernameInLogs)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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_config_freshness":
|
||
return "Config freshness";
|
||
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-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-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 (
|
||
<Badge variant="outline"
|
||
className={cn(
|
||
"text-(length:--text-micro) capitalize",
|
||
workspaceOperationStatusTone(status),
|
||
)}
|
||
>
|
||
{status.replace("_", " ")}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
function WorkspaceOperationLogViewer({
|
||
operation,
|
||
censorUsernameInLogs,
|
||
}: {
|
||
operation: WorkspaceOperation;
|
||
censorUsernameInLogs: boolean;
|
||
}) {
|
||
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-(length:--text-micro) 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">{redactPathText(chunk.chunk, censorUsernameInLogs)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkspaceOperationsSection({
|
||
operations,
|
||
censorUsernameInLogs,
|
||
}: {
|
||
operations: WorkspaceOperation[];
|
||
censorUsernameInLogs: boolean;
|
||
}) {
|
||
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-(length:--text-micro) 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">
|
||
{redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
|
||
</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">
|
||
{redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{operation.logRef && (
|
||
<WorkspaceOperationLogViewer
|
||
operation={operation}
|
||
censorUsernameInLogs={censorUsernameInLogs}
|
||
/>
|
||
)}
|
||
</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, selectionSource, setSelectedCompanyId } = useCompany();
|
||
const { closePanel } = usePanel();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const [actionError, setActionError] = useState<string | null>(null);
|
||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||
const activeView: AgentDetailView = urlRunId ? "run-detail"
|
||
: parseAgentDetailView(urlTab ?? null);
|
||
const legacyAuditSection = !urlRunId ? agentLegacyAuditSection(urlTab ?? null) : null;
|
||
const legacyView = urlRunId
|
||
? "runs"
|
||
: legacyAuditSection === "runs"
|
||
? "runs"
|
||
: legacyAuditSection === "activity"
|
||
? "audit"
|
||
: legacyAuditSection === "costs" || legacyAuditSection === "budgets"
|
||
? "budget"
|
||
: activeView === "overview"
|
||
? "dashboard"
|
||
: activeView === "runtime"
|
||
? "configuration"
|
||
: activeView;
|
||
const needsOverviewData = activeView === "overview";
|
||
const needsRunData = activeView === "run-detail";
|
||
const shouldLoadHeartbeats = needsOverviewData || 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 prepareAgentNavigation = useCallback(() => {
|
||
return confirmAgentConfigNavigation(configDirty);
|
||
}, [configDirty]);
|
||
const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
|
||
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 agentCompany = companies.find((company) => company.id === agent?.companyId);
|
||
const handleLegacyTabChange = useCallback((next: string) => {
|
||
if (!prepareAgentNavigation()) return;
|
||
navigate(`/agents/${canonicalAgentRef || routeAgentRef}/${next}`);
|
||
}, [canonicalAgentRef, navigate, prepareAgentNavigation, routeAgentRef]);
|
||
const agentLookupRef = agent?.id ?? routeAgentRef;
|
||
const resolvedAgentId = agent?.id ?? null;
|
||
const { data: boardAccess } = useQuery({
|
||
queryKey: queryKeys.access.currentBoardAccess,
|
||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||
retry: false,
|
||
});
|
||
const canUseProviderTrace =
|
||
boardAccess?.source === "local_implicit" ||
|
||
boardAccess?.isInstanceAdmin === true;
|
||
const membershipsQuery = useResourceMemberships(resolvedCompanyId);
|
||
const membershipMutation = useResourceMembershipMutation(resolvedCompanyId);
|
||
const agentMembershipState = resolvedAgentId
|
||
? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId)
|
||
: "joined";
|
||
|
||
const { data: experimentalSettings } = useQuery({
|
||
queryKey: queryKeys.instance.experimentalSettings,
|
||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||
enabled: !!resolvedCompanyId,
|
||
});
|
||
const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true;
|
||
const { data: builtInStates } = useQuery({
|
||
queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!),
|
||
queryFn: () => builtInAgentsApi.list(resolvedCompanyId!),
|
||
enabled: !!resolvedCompanyId && builtInAgentsEnabled,
|
||
});
|
||
const builtInState = builtInAgentsEnabled
|
||
? builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null
|
||
: null;
|
||
const builtInFeatureLabel = builtInState
|
||
? builtInState.definition.featureKeys
|
||
.map((key) => key.charAt(0).toUpperCase() + key.slice(1))
|
||
.join(", ")
|
||
: "";
|
||
const invalidateBuiltIn = useCallback(() => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) });
|
||
if (resolvedAgentId) {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) });
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||
}, [queryClient, resolvedCompanyId, resolvedAgentId, routeAgentRef]);
|
||
|
||
const resetBuiltIn = useMutation({
|
||
mutationFn: () => builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key),
|
||
onSuccess: invalidateBuiltIn,
|
||
});
|
||
|
||
const [showBuiltInConfigure, setShowBuiltInConfigure] = useState(false);
|
||
const resetBuiltInResource = useMutation({
|
||
mutationFn: (kind: BuiltInManagedResourceKind) =>
|
||
builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key, [kind]),
|
||
onSuccess: invalidateBuiltIn,
|
||
onError: (error) => {
|
||
setActionError(error instanceof Error ? error.message : "Failed to update bundle resource");
|
||
},
|
||
});
|
||
const runBuiltInRoutine = useMutation({
|
||
mutationFn: (routineKey: string) =>
|
||
builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||
onSuccess: invalidateBuiltIn,
|
||
onError: (error) => {
|
||
setActionError(error instanceof Error ? error.message : "Failed to run built-in routine");
|
||
},
|
||
});
|
||
const enableBuiltInSchedule = useMutation({
|
||
mutationFn: (routineKey: string) =>
|
||
builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||
onSuccess: invalidateBuiltIn,
|
||
onError: (error) => {
|
||
setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule");
|
||
},
|
||
});
|
||
const disableBuiltInSchedule = useMutation({
|
||
mutationFn: (routineKey: string) =>
|
||
builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||
onSuccess: invalidateBuiltIn,
|
||
onError: (error) => {
|
||
setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule");
|
||
},
|
||
});
|
||
const builtInRoutineActionPending =
|
||
runBuiltInRoutine.isPending
|
||
? "run"
|
||
: enableBuiltInSchedule.isPending
|
||
? "enable"
|
||
: disableBuiltInSchedule.isPending
|
||
? "disable"
|
||
: null;
|
||
|
||
const { data: runtimeState } = useQuery({
|
||
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
|
||
queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined),
|
||
enabled: Boolean(resolvedAgentId) && needsOverviewData,
|
||
});
|
||
|
||
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!), "participant-agent", resolvedAgentId ?? "__none__"],
|
||
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
|
||
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsOverviewData,
|
||
});
|
||
|
||
const { data: allAgents } = useQuery({
|
||
queryKey: queryKeys.agents.list(resolvedCompanyId!),
|
||
queryFn: () => agentsApi.list(resolvedCompanyId!),
|
||
enabled: !!resolvedCompanyId && needsOverviewData,
|
||
});
|
||
|
||
const { data: skillSnapshot } = useQuery({
|
||
queryKey: queryKeys.agents.skills(resolvedAgentId ?? "__none__"),
|
||
queryFn: () => agentsApi.skills(resolvedAgentId!, resolvedCompanyId ?? undefined),
|
||
enabled: Boolean(resolvedCompanyId && resolvedAgentId && needsOverviewData),
|
||
});
|
||
|
||
const { data: overviewCompanySkills } = useQuery({
|
||
queryKey: queryKeys.companySkills.list(resolvedCompanyId ?? "__none__"),
|
||
queryFn: () => companySkillsApi.list(resolvedCompanyId!),
|
||
enabled: Boolean(resolvedCompanyId && needsOverviewData),
|
||
});
|
||
|
||
const assignedIssues = useMemo(
|
||
() => [...(allIssues ?? [])].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
|
||
[allIssues],
|
||
);
|
||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||
const overviewSkillNames = useMemo(() => {
|
||
const namesByKey = new Map((overviewCompanySkills ?? []).map((skill) => [skill.key, skill.name]));
|
||
return (skillSnapshot?.desiredSkills ?? []).map((key) => namesByKey.get(key) ?? key);
|
||
}, [overviewCompanySkills, skillSnapshot?.desiredSkills]);
|
||
const mobileLiveRun = useMemo(
|
||
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
|
||
[heartbeats],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!agent) return;
|
||
if (!agentCompany) return;
|
||
const companyMismatch = companyPrefix?.toUpperCase() !== agentCompany.issuePrefix.toUpperCase();
|
||
if (routeAgentRef !== canonicalAgentRef) {
|
||
// Reuse the authorized response across the alias redirect. An empty
|
||
// alias query would unmount the selected run and discard its mutations.
|
||
// UUID lookups can return another accessible company's agent. Its alias
|
||
// belongs only to that company's cache, even if the URL used another scope.
|
||
queryClient.setQueryData(
|
||
[...queryKeys.agents.detail(canonicalAgentRef), agent.companyId],
|
||
agent,
|
||
);
|
||
}
|
||
if (urlRunId) {
|
||
if (routeAgentRef !== canonicalAgentRef || companyMismatch) {
|
||
navigate(`/${agentCompany.issuePrefix}/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
if (legacyAuditSection) return;
|
||
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
|
||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab || companyMismatch) {
|
||
navigate(`/${agentCompany.issuePrefix}${agentDetailHref(canonicalAgentRef, canonicalTab)}`, { replace: true });
|
||
return;
|
||
}
|
||
}, [agent, agentCompany, companyPrefix, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, queryClient]);
|
||
|
||
useEffect(() => {
|
||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||
// Let an explicit organization switch finish its navigation first.
|
||
if (selectionSource === "manual") return;
|
||
// The route owns selection until canonical navigation reaches this company.
|
||
// Competing with Layout's old-prefix selection can cause an update loop.
|
||
if (routeCompanyId && routeCompanyId !== agent.companyId) return;
|
||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||
}, [agent?.companyId, routeCompanyId, selectedCompanyId, selectionSource, setSelectedCompanyId]);
|
||
|
||
// Invoke / pause / resume / terminate / duplicate / reset live in the shared
|
||
// AgentActionButtons component. The detail header keeps only "approve" here,
|
||
// which is surfaced via the pending-approval banner below.
|
||
const agentAction = useMutation({
|
||
mutationFn: async (action: "approve") => {
|
||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||
if (action === "approve") {
|
||
return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||
}
|
||
},
|
||
onSuccess: () => {
|
||
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) });
|
||
}
|
||
}
|
||
},
|
||
onError: (err) => {
|
||
setActionError(err instanceof Error ? err.message : "Action failed");
|
||
},
|
||
});
|
||
|
||
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 updatePermissions = useMutation({
|
||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||
agentsApi.updatePermissions(agentLookupRef, permissions, 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 === "overview" && !urlRunId) {
|
||
crumbs.push({ label: agentName });
|
||
} else {
|
||
crumbs.push({ label: agentName, href: agentDetailHref(canonicalAgentRef) });
|
||
if (urlRunId) {
|
||
crumbs.push({ label: "Runs", href: agent?.id ? agentScopedAuditHref(agent.id, "runs") : undefined });
|
||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||
} else {
|
||
const item = AGENT_DETAIL_NAVIGATION
|
||
.flatMap((section) => section.items)
|
||
.find((candidate) => candidate.value === activeView);
|
||
crumbs.push({ label: item?.label ?? "Overview" });
|
||
}
|
||
}
|
||
setBreadcrumbs(crumbs);
|
||
}, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
|
||
|
||
useEffect(() => {
|
||
closePanel();
|
||
return () => closePanel();
|
||
}, [closePanel]);
|
||
|
||
useEffect(() => {
|
||
if (!resolvedAgentId || agentMembershipState !== "joined") return;
|
||
setDismissedLeftAgentIds((current) => {
|
||
if (!current.has(resolvedAgentId)) return current;
|
||
const next = new Set(current);
|
||
next.delete(resolvedAgentId);
|
||
return next;
|
||
});
|
||
}, [resolvedAgentId, agentMembershipState]);
|
||
|
||
useBeforeUnload(
|
||
useCallback((event) => {
|
||
if (!configDirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
}, [configDirty]),
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!configDirty) return;
|
||
|
||
function handleDocumentClick(event: MouseEvent) {
|
||
if (
|
||
event.defaultPrevented ||
|
||
event.button !== 0 ||
|
||
event.altKey ||
|
||
event.ctrlKey ||
|
||
event.metaKey ||
|
||
event.shiftKey
|
||
) {
|
||
return;
|
||
}
|
||
const target = event.target;
|
||
if (!(target instanceof Element)) return;
|
||
const anchor = target.closest("a[href]");
|
||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||
if (anchor.target && anchor.target !== "_self") return;
|
||
|
||
const nextUrl = new URL(anchor.href, window.location.href);
|
||
const currentUrl = new URL(window.location.href);
|
||
if (nextUrl.origin !== currentUrl.origin) return;
|
||
if (
|
||
nextUrl.pathname === currentUrl.pathname &&
|
||
nextUrl.search === currentUrl.search &&
|
||
nextUrl.hash === currentUrl.hash
|
||
) {
|
||
return;
|
||
}
|
||
if (prepareAgentNavigation()) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
|
||
document.addEventListener("click", handleDocumentClick, true);
|
||
return () => document.removeEventListener("click", handleDocumentClick, true);
|
||
}, [configDirty, prepareAgentNavigation]);
|
||
|
||
useEffect(() => {
|
||
if (!configDirty) return;
|
||
|
||
// BrowserRouter updates after popstate. Run first in the capture phase so a
|
||
// rejected Back/Forward navigation can be restored before React Router
|
||
// consumes it and unmounts the route-backed form.
|
||
const currentEntry = {
|
||
index: window.history.state?.idx,
|
||
state: window.history.state,
|
||
url: window.location.href,
|
||
};
|
||
let restoring = false;
|
||
|
||
function handlePopState(event: PopStateEvent) {
|
||
if (restoring) {
|
||
restoring = false;
|
||
return;
|
||
}
|
||
|
||
if (prepareAgentNavigation()) return;
|
||
|
||
event.stopImmediatePropagation();
|
||
restoring = restoreAgentConfigHistoryEntry(window.history, currentEntry, event.state?.idx);
|
||
}
|
||
|
||
window.addEventListener("popstate", handlePopState, true);
|
||
return () => window.removeEventListener("popstate", handlePopState, true);
|
||
}, [configDirty, prepareAgentNavigation]);
|
||
|
||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||
if (!agent) return null;
|
||
if (!urlRunId && legacyAuditSection) {
|
||
return <Navigate to={applyCompanyPrefix(agentScopedAuditHref(agent.id, legacyAuditSection), agentCompany?.issuePrefix)} replace />;
|
||
}
|
||
if (!urlRunId && !urlTab) {
|
||
return <Navigate to={applyCompanyPrefix(agentDetailHref(canonicalAgentRef), agentCompany?.issuePrefix)} replace />;
|
||
}
|
||
const isPendingApproval = agent.status === "pending_approval";
|
||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||
const pausedEscalationWarning = !hasInvalidOrgChain ? agent.orgChainHealth?.escalationWarning ?? null : null;
|
||
const showConfigActionBar = (
|
||
activeView === "runtime" || activeView === "instructions" || activeView === "secrets"
|
||
);
|
||
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
|
||
const agentMembershipPending =
|
||
membershipMutation.isPending &&
|
||
membershipMutation.variables?.resourceType === "agent" &&
|
||
membershipMutation.variables.resourceId === agent.id;
|
||
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
|
||
const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined;
|
||
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
|
||
|
||
return (
|
||
<div className={cn("agent-settings-content mx-auto max-w-5xl space-y-8", `agent-settings-${activeView}`)}>
|
||
{showLeftAgentNotice ? (
|
||
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-900 dark:text-yellow-100">
|
||
<p className="min-w-0 flex-1">
|
||
You left this agent. It no longer appears in your sidebar.
|
||
</p>
|
||
<MembershipAction
|
||
compact
|
||
state="left"
|
||
pending={agentJoinLeavePending}
|
||
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state : null}
|
||
resourceName={agent.name}
|
||
onJoin={() => membershipMutation.mutate({
|
||
resourceType: "agent",
|
||
resourceId: agent.id,
|
||
resourceName: agent.name,
|
||
state: "joined",
|
||
})}
|
||
onLeave={() => membershipMutation.mutate({
|
||
resourceType: "agent",
|
||
resourceId: agent.id,
|
||
resourceName: agent.name,
|
||
state: "left",
|
||
})}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="h-6 w-6 shrink-0 text-yellow-900/70 hover:text-yellow-900 dark:text-yellow-100/70 dark:hover:text-yellow-100"
|
||
aria-label="Dismiss agent membership notice"
|
||
onClick={() => setDismissedLeftAgentIds((current) => new Set(current).add(agent.id))}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{pausedEscalationWarning ? (
|
||
<div className="flex items-start gap-3 border border-amber-300/35 bg-amber-300/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<div className="min-w-0 space-y-1">
|
||
<p className="font-medium">Escalation path is paused</p>
|
||
<p className="text-amber-900/90 dark:text-amber-100/90">{pausedEscalationWarning}</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{hasInvalidOrgChain ? (
|
||
<div className="flex items-start gap-3 border border-amber-300/35 bg-amber-300/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<div className="min-w-0 space-y-1">
|
||
<p className="font-medium">Invalid reporting chain</p>
|
||
<p className="text-amber-900/90 dark:text-amber-100/90">
|
||
{agent.name} cannot accept tasks or start runs until its reporting chain is repaired.
|
||
</p>
|
||
<p className="break-words font-mono text-xs text-amber-900/80 dark:text-amber-100/80">
|
||
{formatOrgChainHealthPath(agent)}
|
||
</p>
|
||
{agent.orgChainHealth?.repairGuidance ? (
|
||
<p className="text-amber-900/85 dark:text-amber-100/85">{agent.orgChainHealth.repairGuidance}</p>
|
||
) : (
|
||
<p className="text-amber-900/85 dark:text-amber-100/85">
|
||
Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{/* Header */}
|
||
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
|
||
<div className="flex min-w-0 items-center gap-4">
|
||
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0">
|
||
<AgentCharacter agent={agent} state={characterStateForAgent(agent.status)} size={96} trackingScope="page" />
|
||
</div>
|
||
<div className="min-w-0 space-y-1">
|
||
<div className="flex items-center gap-2"><h1 className="truncate text-2xl font-semibold tracking-tight">{agent.name}</h1><PrimaryAgentIndicator agentId={agent.id} companyId={agent.companyId} /></div>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
{agent.adapterType === "claude_local" || agent.adapterType === "codex_local"
|
||
? <img src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`} className="size-4" alt="" />
|
||
: null}
|
||
<span>{getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}</span><span>·</span>
|
||
<span>{agent.title || roleLabels[agent.role] || agent.role}</span>
|
||
</div>
|
||
<SetPrimaryAgentButton agent={agent} />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<StarToggle
|
||
size="button"
|
||
starred={agentStarred}
|
||
pending={agentStarPending}
|
||
resourceName={agent.name}
|
||
onToggle={(next) => membershipMutation.mutate({
|
||
resourceType: "agent",
|
||
resourceId: agent.id,
|
||
resourceName: agent.name,
|
||
starred: next,
|
||
})}
|
||
/>
|
||
<AgentActionButtons
|
||
agent={agent}
|
||
companyId={resolvedCompanyId}
|
||
assignLabel="Assign Task"
|
||
showStatus={false}
|
||
canRunWithProviderTrace={canUseProviderTrace}
|
||
actionsDisabled={agentAction.isPending}
|
||
workActionsDisabled={hasInvalidOrgChain}
|
||
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
||
hasPendingNavigationChanges={configDirty}
|
||
onBeforeNavigate={prepareAgentNavigation}
|
||
onActionError={setActionError}
|
||
onTerminateSuccess={() => navigate("/agents/all", { replace: true })}
|
||
hideTerminate={Boolean(builtInState)}
|
||
pauseConfirm={
|
||
builtInState
|
||
? {
|
||
title: `Pause the ${builtInState.definition.displayName}?`,
|
||
description: (
|
||
<>
|
||
{builtInFeatureLabel} depends on this agent. While paused,{" "}
|
||
{builtInFeatureLabel.toLowerCase()} generation is skipped and the{" "}
|
||
{builtInFeatureLabel} page shows a warning.
|
||
</>
|
||
),
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{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-(length:--text-micro) font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||
</Link>
|
||
)}
|
||
</AgentActionButtons>
|
||
</div>
|
||
</header>
|
||
|
||
{builtInState && (
|
||
<InlineBanner
|
||
tone="info"
|
||
title="Built-in agent"
|
||
actions={
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => resetBuiltIn.mutate()}
|
||
disabled={resetBuiltIn.isPending}
|
||
>
|
||
{resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"}
|
||
</Button>
|
||
}
|
||
>
|
||
Ships with Paperclip and powers <strong>{builtInFeatureLabel}</strong>. Configure it like
|
||
any agent — model, instructions, budget. It can be paused but not deleted; pausing it
|
||
pauses {builtInFeatureLabel}.
|
||
</InlineBanner>
|
||
)}
|
||
|
||
{builtInState?.definition.bundle && (
|
||
<BuiltInBundlePanel
|
||
state={builtInState}
|
||
agentRef={canonicalAgentRef}
|
||
onConfigure={() => setShowBuiltInConfigure(true)}
|
||
onResetResource={(kind) => resetBuiltInResource.mutate(kind)}
|
||
onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)}
|
||
onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)}
|
||
onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)}
|
||
resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null}
|
||
routineActionPending={builtInRoutineActionPending}
|
||
/>
|
||
)}
|
||
|
||
{builtInState && resolvedCompanyId && (
|
||
<ConfigureBuiltInAgentModal
|
||
companyId={resolvedCompanyId}
|
||
state={builtInState}
|
||
open={showBuiltInConfigure}
|
||
onOpenChange={setShowBuiltInConfigure}
|
||
onConfigured={() => {
|
||
setShowBuiltInConfigure(false);
|
||
invalidateBuiltIn();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
|
||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||
{isPendingApproval && (
|
||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||
<span>This agent is pending board approval and cannot be invoked yet.</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => agentAction.mutate("approve")}
|
||
disabled={agentAction.isPending}
|
||
>
|
||
<CheckCircle2 className="h-3.5 w-3.5 sm:mr-1" />
|
||
<span>Approve agent</span>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{activeView !== "run-detail" && activeView !== "channels" && <h2 className="text-xl font-semibold">{activeView === "secrets" ? "Secrets & variables" : AGENT_DETAIL_NAVIGATION.flatMap(section => section.items).find(item => item.value === activeView)?.label}</h2>}
|
||
|
||
{/* View content */}
|
||
{activeView === "overview" && (
|
||
<AgentOverview
|
||
agent={agent}
|
||
runs={heartbeats ?? []}
|
||
assignedIssues={assignedIssues}
|
||
runtimeState={runtimeState}
|
||
reportsToAgent={reportsToAgent}
|
||
directReportCount={directReports.length}
|
||
skillNames={overviewSkillNames}
|
||
agentRouteId={canonicalAgentRef}
|
||
/>
|
||
)}
|
||
|
||
{activeView === "instructions" && (
|
||
<div><PromptsTab
|
||
agent={agent}
|
||
companyId={resolvedCompanyId ?? undefined}
|
||
showSaveNotice={false}
|
||
onDirtyChange={setConfigDirty}
|
||
onSaveActionChange={setSaveConfigAction}
|
||
onCancelActionChange={setCancelConfigAction}
|
||
onSavingChange={setConfigSaving}
|
||
/>
|
||
{resolvedCompanyId && <AgentConnectionInstructions companyId={resolvedCompanyId} agentId={agent.id} />}
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "runtime" && (
|
||
<div>
|
||
<ConfigurationTab
|
||
agent={agent}
|
||
companyId={resolvedCompanyId ?? undefined}
|
||
onDirtyChange={setConfigDirty}
|
||
onSaveActionChange={setSaveConfigAction}
|
||
onCancelActionChange={setCancelConfigAction}
|
||
onSavingChange={setConfigSaving}
|
||
updatePermissions={updatePermissions}
|
||
canConfigureProviderTrace={canUseProviderTrace}
|
||
content="runtime"
|
||
hidePromptTemplate
|
||
hideInstructionsFile
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "secrets" && (
|
||
<div>
|
||
<ConfigurationTab
|
||
agent={agent}
|
||
companyId={resolvedCompanyId ?? undefined}
|
||
onDirtyChange={setConfigDirty}
|
||
onSaveActionChange={setSaveConfigAction}
|
||
onCancelActionChange={setCancelConfigAction}
|
||
onSavingChange={setConfigSaving}
|
||
updatePermissions={updatePermissions}
|
||
content="secrets"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "skills" && (
|
||
<AgentSkillsTab
|
||
agent={agent}
|
||
companyId={resolvedCompanyId ?? undefined}
|
||
/>
|
||
)}
|
||
|
||
{activeView === "tools" && resolvedCompanyId && (
|
||
<AgentToolsTab agent={agent} companyId={resolvedCompanyId} />
|
||
)}
|
||
|
||
{activeView === "channels" && resolvedCompanyId && (
|
||
<AgentChannelsPanel agentId={agent.id} companyId={resolvedCompanyId} />
|
||
)}
|
||
|
||
{activeView === "permissions" && (
|
||
<div>
|
||
<ConfigurationTab
|
||
agent={agent}
|
||
companyId={resolvedCompanyId ?? undefined}
|
||
onDirtyChange={setConfigDirty}
|
||
onSaveActionChange={setSaveConfigAction}
|
||
onCancelActionChange={setCancelConfigAction}
|
||
onSavingChange={setConfigSaving}
|
||
updatePermissions={updatePermissions}
|
||
content="permissions"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "api-keys" && (
|
||
<div className="max-w-3xl">
|
||
<KeysTab agentId={agent.id} companyId={resolvedCompanyId ?? undefined} />
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "revisions" && (
|
||
<AgentRevisionsTab agent={agent} companyId={resolvedCompanyId ?? undefined} />
|
||
)}
|
||
|
||
{activeView === "run-detail" && (
|
||
<RunsTab
|
||
runs={heartbeats ?? []}
|
||
companyId={resolvedCompanyId!}
|
||
agentId={agent.id}
|
||
agentRouteId={canonicalAgentRef}
|
||
selectedRunId={urlRunId ?? null}
|
||
adapterType={agent.adapterType}
|
||
adapterConfig={agent.adapterConfig}
|
||
/>
|
||
)}
|
||
|
||
{showConfigActionBar && <footer className="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
|
||
<p role="status" className="text-xs text-muted-foreground">{configSaving ? "Saving changes…" : configDirty ? "You have unsaved changes." : ""}</p>
|
||
<div className="flex gap-2">
|
||
<Button variant="ghost" disabled={!configDirty || configSaving} onClick={() => cancelConfigActionRef.current?.()}>Discard</Button>
|
||
<Button disabled={!configDirty || configSaving} onClick={() => {Promise.resolve(saveConfigActionRef.current?.()).catch(() => {});}}>{configSaving ? "Saving…" : "Save changes"}</Button>
|
||
</div>
|
||
</footer>}
|
||
|
||
</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>
|
||
);
|
||
}
|
||
|
||
export type LatestRunIssue = { id: string; title: string; status: string; identifier?: string | null };
|
||
|
||
/**
|
||
* The id of the issue a run works on, read from its context snapshot. Newer
|
||
* snapshots use `issueId`; older ones use `taskId`. Returns undefined for pure
|
||
* timer heartbeats that carry no task reference.
|
||
*/
|
||
export function getRunSnapshotIssueId(
|
||
run: Pick<HeartbeatRun, "contextSnapshot">,
|
||
): string | undefined {
|
||
const ctx = run.contextSnapshot as Record<string, unknown> | null;
|
||
const issueId = ctx?.issueId ?? ctx?.taskId;
|
||
return issueId ? String(issueId) : undefined;
|
||
}
|
||
|
||
/**
|
||
* Resolve the Live Run section's two navigation destinations and the task (if
|
||
* any) the run works on. The run→task link lives in the run's context snapshot
|
||
* (`issueId`, falling back to `taskId` for older snapshots); the `HeartbeatRun`
|
||
* itself doesn't carry the issue id. The heading always links to the run detail
|
||
* page; the running row links to the task detail page when the snapshot resolves
|
||
* to a known issue, otherwise falls back to the run detail page (pure timer
|
||
* heartbeats or an issue that can't be resolved).
|
||
*/
|
||
export function resolveLatestRunNavigation(
|
||
run: Pick<HeartbeatRun, "id" | "contextSnapshot">,
|
||
agentId: string,
|
||
issuesById: Map<string, LatestRunIssue>,
|
||
): { task: LatestRunIssue | undefined; runHref: string; rowHref: string } {
|
||
const issueId = getRunSnapshotIssueId(run);
|
||
const task = issueId ? issuesById.get(issueId) : undefined;
|
||
const runHref = `/agents/${agentId}/runs/${run.id}`;
|
||
const rowHref = task ? `/issues/${task.identifier ?? task.id}` : runHref;
|
||
return { task, runHref, rowHref };
|
||
}
|
||
|
||
function LatestRunCard({
|
||
runs,
|
||
agentId,
|
||
issuesById,
|
||
}: {
|
||
runs: HeartbeatRun[];
|
||
agentId: string;
|
||
issuesById: Map<string, LatestRunIssue>;
|
||
}) {
|
||
const sorted = useMemo(
|
||
() =>
|
||
[...runs].sort(
|
||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||
),
|
||
[runs]
|
||
);
|
||
|
||
const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued");
|
||
const run = liveRun ?? sorted[0];
|
||
|
||
// The assigned-issues list this card resolves against is bounded (server page
|
||
// limit), so a live run can reference a valid issue that isn't on the loaded
|
||
// page. When the snapshot points at an issue we don't already have, fetch it
|
||
// directly so the running row always links to the task rather than falling
|
||
// back to run metadata. `enabled` keeps this a no-op for the common case.
|
||
const snapshotIssueId = run ? getRunSnapshotIssueId(run) : undefined;
|
||
const needsFallbackFetch = !!snapshotIssueId && !issuesById.has(snapshotIssueId);
|
||
const { data: fallbackIssue } = useQuery({
|
||
queryKey: queryKeys.issues.detail(snapshotIssueId ?? "__none__"),
|
||
queryFn: () => issuesApi.get(snapshotIssueId as string),
|
||
enabled: needsFallbackFetch,
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const summaryRaw = run
|
||
? run.resultJson
|
||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||
: run.error ?? ""
|
||
: "";
|
||
|
||
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
|
||
const summary = useMemo(() => {
|
||
if (!summaryRaw) return "";
|
||
const lines = summaryRaw
|
||
.replace(/^#{1,6}\s+/gm, "")
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
|
||
const excerpt: string[] = [];
|
||
let chars = 0;
|
||
for (const line of lines) {
|
||
if (excerpt.length >= 3 || chars + line.length > 280) break;
|
||
excerpt.push(line);
|
||
chars += line.length;
|
||
}
|
||
return excerpt.join(" ");
|
||
}, [summaryRaw]);
|
||
|
||
if (!run) return null;
|
||
|
||
const isLive = run.status === "running" || run.status === "queued";
|
||
// Fold any directly-fetched fallback issue into the lookup, keyed by the same
|
||
// snapshot id used to resolve the row so it hits regardless of id-vs-slug.
|
||
const effectiveIssuesById =
|
||
fallbackIssue && snapshotIssueId
|
||
? new Map(issuesById).set(snapshotIssueId, {
|
||
id: fallbackIssue.id,
|
||
title: fallbackIssue.title,
|
||
status: fallbackIssue.status,
|
||
identifier: fallbackIssue.identifier,
|
||
})
|
||
: issuesById;
|
||
const { task, runHref, rowHref } = resolveLatestRunNavigation(run, agentId, effectiveIssuesById);
|
||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||
const StatusIcon = statusInfo.icon;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex w-full items-center justify-between">
|
||
<Link
|
||
to={runHref}
|
||
className="no-underline"
|
||
>
|
||
<h3 className="flex items-center gap-2 text-sm font-medium transition-colors hover:text-foreground">
|
||
{isLive && (
|
||
<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>{isLive ? "Live Run" : "Latest Run"}</span>
|
||
<span className="font-mono text-xs font-normal text-muted-foreground">
|
||
· {run.id.slice(0, 8)}
|
||
</span>
|
||
</h3>
|
||
</Link>
|
||
</div>
|
||
|
||
<Link
|
||
to={rowHref}
|
||
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-blue-500/30 shadow-(--shadow-extract-14)" : "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} />
|
||
{task ? (
|
||
<>
|
||
<StatusGlyph status={task.status} size="sm" />
|
||
<span className="font-mono text-xs text-muted-foreground">
|
||
{task.identifier ?? task.id.slice(0, 8)}
|
||
</span>
|
||
<span className="truncate text-xs">{task.title}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||
<Badge variant="ghost" className={cn(
|
||
"px-1.5 text-(length:--text-nano)",
|
||
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}
|
||
</Badge>
|
||
</>
|
||
)}
|
||
<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 ---- */
|
||
|
||
export function AgentOverview({
|
||
agent,
|
||
runs,
|
||
assignedIssues,
|
||
runtimeState,
|
||
reportsToAgent,
|
||
directReportCount,
|
||
skillNames,
|
||
agentRouteId,
|
||
}: {
|
||
agent: AgentDetailRecord;
|
||
runs: HeartbeatRun[];
|
||
assignedIssues: Issue[];
|
||
runtimeState?: AgentRuntimeState;
|
||
reportsToAgent?: Agent;
|
||
directReportCount: number;
|
||
skillNames: string[];
|
||
agentRouteId: string;
|
||
}) {
|
||
const issuesById = useMemo(() => {
|
||
const map = new Map<string, (typeof assignedIssues)[number]>();
|
||
for (const issue of assignedIssues) map.set(issue.id, issue);
|
||
return map;
|
||
}, [assignedIssues]);
|
||
const configuredModel = asNonEmptyString(agent.adapterConfig?.model)
|
||
?? asNonEmptyString(agent.adapterConfig?.modelName)
|
||
?? asNonEmptyString(agent.runtimeConfig?.model)
|
||
?? "Adapter default";
|
||
const lastRun = runs[0] ?? null;
|
||
const identity = useQuery({
|
||
queryKey: [...queryKeys.agents.identity(agent.id), lastRun?.id, lastRun?.status],
|
||
queryFn: () => agentsApi.getIdentity(agent.id, agent.companyId),
|
||
});
|
||
return (
|
||
<div className="space-y-6">
|
||
<LatestRunCard runs={runs} agentId={agentRouteId} issuesById={issuesById} />
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<section className="rounded-lg border border-border p-4" aria-labelledby="agent-identity-heading">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<h3 id="agent-identity-heading" className="text-sm font-medium">Identity</h3>
|
||
<StatusBadge status={agent.status} />
|
||
</div>
|
||
<div className="space-y-3">
|
||
<SummaryRow label="Role"><span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span></SummaryRow>
|
||
<SummaryRow label="Title"><span className="text-sm">{agent.title ?? "Not set"}</span></SummaryRow>
|
||
<SummaryRow label="Reports to">
|
||
{reportsToAgent ? (
|
||
<Link className="text-sm hover:underline" to={agentDetailHref(agentRouteRef(reportsToAgent))}>
|
||
{reportsToAgent.name}
|
||
</Link>
|
||
) : <span className="text-sm">Board</span>}
|
||
</SummaryRow>
|
||
<SummaryRow label="Direct reports"><span className="text-sm tabular-nums">{directReportCount}</span></SummaryRow>
|
||
<SummaryRow label="Public key">
|
||
{identity.isPending ? <span className="text-sm text-muted-foreground">Loading…</span>
|
||
: identity.isError ? <span className="text-sm text-destructive">Could not load public key</span>
|
||
: identity.data ? (
|
||
<CopyText text={identity.data.publicKeyPem} ariaLabel="Copy public key" title="Copy public key">
|
||
<span className="font-mono text-sm">{identity.data.keyId.slice(0, 19)}…</span>
|
||
</CopyText>
|
||
) : (
|
||
<div className="text-sm">
|
||
<div>Not created yet</div>
|
||
<div className="text-xs text-muted-foreground">Created automatically on the next managed run</div>
|
||
</div>
|
||
)}
|
||
</SummaryRow>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-border p-4" aria-labelledby="agent-runtime-heading">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<h3 id="agent-runtime-heading" className="text-sm font-medium">Harness / Runtime</h3>
|
||
<Link className="text-xs text-muted-foreground hover:text-foreground" to={agentDetailHref(agentRouteId, "runtime")}>Configure</Link>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<SummaryRow label="Adapter"><span className="text-sm">{getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}</span></SummaryRow>
|
||
<SummaryRow label="Model"><span className="max-w-64 truncate text-sm font-mono">{configuredModel}</span></SummaryRow>
|
||
<SummaryRow label="Session"><span className="max-w-64 truncate text-sm font-mono">{runtimeState?.sessionDisplayId ?? runtimeState?.sessionId ?? "No session"}</span></SummaryRow>
|
||
<SummaryRow label="Last run">
|
||
<span className="text-sm">{lastRun ? `${lastRun.status} · ${relativeTime(lastRun.createdAt)}` : "No runs"}</span>
|
||
</SummaryRow>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-border p-4" aria-labelledby="agent-capabilities-heading">
|
||
<h3 id="agent-capabilities-heading" className="mb-3 text-sm font-medium">Capabilities</h3>
|
||
{agent.capabilities?.trim() ? (
|
||
<MarkdownBody className="text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{agent.capabilities}</MarkdownBody>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No capability summary has been added.</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-border p-4" aria-labelledby="agent-skills-heading">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<h3 id="agent-skills-heading" className="text-sm font-medium">Skills</h3>
|
||
<Link className="text-xs text-muted-foreground hover:text-foreground" to={agentDetailHref(agentRouteId, "skills")}>Manage</Link>
|
||
</div>
|
||
{skillNames.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{skillNames.slice(0, 8).map((skill) => <Badge key={skill} variant="secondary">{skill}</Badge>)}
|
||
{skillNames.length > 8 ? <Badge variant="outline">+{skillNames.length - 8} more</Badge> : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No skills enabled.</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<section className="space-y-3" aria-labelledby="agent-recent-tasks-heading">
|
||
<div className="flex items-center justify-between">
|
||
<h3 id="agent-recent-tasks-heading" className="text-sm font-medium">Recent Tasks</h3>
|
||
<Link
|
||
to={`/issues?participantAgentId=${agent.id}`}
|
||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
See All →
|
||
</Link>
|
||
</div>
|
||
{assignedIssues.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No recent tasks.</p>
|
||
) : (
|
||
<div className="overflow-hidden rounded-lg border border-border">
|
||
{assignedIssues.slice(0, 6).map((issue) => (
|
||
<IssueRow
|
||
key={issue.id}
|
||
issue={issue}
|
||
presentation="task"
|
||
metadata={<span className="text-xs text-muted-foreground">{relativeTime(issue.updatedAt)}</span>}
|
||
showDivider
|
||
/>
|
||
))}
|
||
{assignedIssues.length > 6 && (
|
||
<div className="border-t border-border px-3 py-2 text-center text-xs text-muted-foreground">
|
||
+{assignedIssues.length - 6} more tasks
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="space-y-3" aria-labelledby="agent-audit-links-heading">
|
||
<h3 id="agent-audit-links-heading" className="text-sm font-medium">Audit</h3>
|
||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||
{(["activity", "runs", "costs", "budgets"] as const).map((section) => (
|
||
<Link
|
||
key={section}
|
||
to={agentScopedAuditHref(agent.id, section)}
|
||
className="rounded-lg border border-border px-3 py-2 text-sm font-medium capitalize hover:bg-accent"
|
||
>
|
||
{section}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---- Agent Configure Page ---- */
|
||
|
||
/**
|
||
* Agent detail URLs use a name-derived key, so updates that change the agent's
|
||
* name (a rename or a config-revision rollback) can invalidate the reference
|
||
* currently in the URL. When that happens, refetching the old reference would
|
||
* 404 with "Agent not found". Instead, drop the stale cached queries and
|
||
* replace the URL with the new canonical reference. Returns true when a
|
||
* redirect happened.
|
||
*/
|
||
export function syncAgentRouteAfterRename(
|
||
queryClient: QueryClient,
|
||
navigate: NavigateFunction,
|
||
previous: { id: string; urlKey?: string | null; name?: string | null },
|
||
updated: { id: string; urlKey?: string | null; name?: string | null },
|
||
tab: string,
|
||
): boolean {
|
||
const previousRef = agentRouteRef(previous);
|
||
const nextRef = agentRouteRef(updated);
|
||
if (nextRef === previousRef) return false;
|
||
queryClient.removeQueries({ queryKey: queryKeys.agents.detail(previousRef) });
|
||
navigate(`/agents/${nextRef}/${tab}`, { replace: true });
|
||
return true;
|
||
}
|
||
|
||
export function AgentRevisionsTab({
|
||
agent,
|
||
companyId,
|
||
}: {
|
||
agent: AgentDetailRecord;
|
||
companyId?: string;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
|
||
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: (updated) => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, "revisions")) {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||
}
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-3xl space-y-3">
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<h3 className="text-sm font-medium">Configuration Revisions</h3>
|
||
<span className="text-xs text-muted-foreground">{configRevisions?.length ?? 0} total</span>
|
||
</div>
|
||
{(configRevisions ?? []).length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No configuration revisions yet.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{(configRevisions ?? []).map((revision) => (
|
||
<div key={revision.id} className="space-y-2 rounded-md border border-border p-3">
|
||
<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"
|
||
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>
|
||
);
|
||
}
|
||
|
||
/* ---- Configuration Tab ---- */
|
||
|
||
export function ConfigurationTab({
|
||
agent,
|
||
companyId,
|
||
onDirtyChange,
|
||
onSaveActionChange,
|
||
onCancelActionChange,
|
||
onSavingChange,
|
||
updatePermissions,
|
||
hidePromptTemplate,
|
||
hideInstructionsFile,
|
||
content = "runtime",
|
||
canConfigureProviderTrace = false,
|
||
}: {
|
||
agent: AgentDetailRecord;
|
||
companyId?: string;
|
||
onDirtyChange: (dirty: boolean) => void;
|
||
onSaveActionChange: (save: (() => void) | null) => void;
|
||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||
onSavingChange: (saving: boolean) => void;
|
||
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
|
||
hidePromptTemplate?: boolean;
|
||
hideInstructionsFile?: boolean;
|
||
content?: "runtime" | "permissions" | "secrets";
|
||
canConfigureProviderTrace?: boolean;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const { tab: urlTab } = useParams<{ tab?: string }>();
|
||
const { pushToast } = useToastActions();
|
||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||
const lastAgentRef = useRef(agent);
|
||
|
||
const catalogProvider = agent.adapterType === "paperclip_runner" ? String(agent.adapterConfig.provider ?? "codex") : undefined;
|
||
const { data: adapterModels } = useQuery({
|
||
queryKey:
|
||
companyId
|
||
? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider)
|
||
: ["agents", "none", "adapter-models", agent.adapterType],
|
||
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }),
|
||
enabled: Boolean(companyId) && content === "runtime",
|
||
});
|
||
|
||
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
|
||
|
||
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
|
||
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"],
|
||
queryFn: () => projectsApi.list(companyId!),
|
||
enabled: Boolean(companyId && lowTrustSelected) && content === "permissions",
|
||
});
|
||
|
||
const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({
|
||
queryKey: companyId
|
||
? [...queryKeys.issues.list(companyId), "low-trust-boundary-candidates"]
|
||
: ["issues", "__low-trust-disabled"],
|
||
queryFn: () => issuesApi.list(companyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
|
||
enabled: Boolean(companyId && lowTrustSelected) && content === "permissions",
|
||
});
|
||
|
||
const updateAgent = useMutation({
|
||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||
onMutate: () => {
|
||
setAwaitingRefreshAfterSave(true);
|
||
},
|
||
onSuccess: (updated) => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
|
||
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? content)) {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||
}
|
||
pushToast({ title: "Agent saved", tone: "success" });
|
||
},
|
||
onError: (err) => {
|
||
setAwaitingRefreshAfterSave(false);
|
||
const message =
|
||
err instanceof ApiError
|
||
? err.message
|
||
: err instanceof Error
|
||
? err.message
|
||
: "Could not save agent";
|
||
pushToast({ title: "Save failed", body: message, tone: "error" });
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||
setAwaitingRefreshAfterSave(false);
|
||
}
|
||
lastAgentRef.current = agent;
|
||
}, [agent, awaitingRefreshAfterSave]);
|
||
const isConfigSaving = content !== "permissions" && (updateAgent.isPending || awaitingRefreshAfterSave);
|
||
|
||
useEffect(() => {
|
||
onSavingChange(isConfigSaving);
|
||
}, [onSavingChange, isConfigSaving]);
|
||
|
||
useEffect(() => {
|
||
if (content !== "permissions") return;
|
||
onDirtyChange(false);
|
||
onSaveActionChange(null);
|
||
onCancelActionChange(null);
|
||
}, [content, onCancelActionChange, onDirtyChange, onSaveActionChange]);
|
||
|
||
const canCreateAgents = Boolean(agent.permissions?.canCreateAgents);
|
||
const canCreateSkills = agent.permissions?.canCreateSkills !== false;
|
||
const canAssignTasks = Boolean(agent.access?.canAssignTasks);
|
||
const taskAssignSource = agent.access?.taskAssignSource ?? "none";
|
||
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
|
||
const taskAssignHint =
|
||
taskAssignSource === "ceo_role"
|
||
? "Enabled automatically for CEO agents."
|
||
: taskAssignSource === "agent_creator"
|
||
? "Enabled automatically while this agent can create new agents."
|
||
: taskAssignSource === "explicit_grant"
|
||
? "Enabled via explicit organization permission grant."
|
||
: taskAssignSource === "simple_default"
|
||
? "Enabled by simple organization-wide task assignment defaults."
|
||
: "Disabled unless explicitly granted.";
|
||
|
||
return (
|
||
<div className="agent-settings-form space-y-6">
|
||
{content !== "permissions" ? <AgentConfigForm
|
||
mode="edit"
|
||
agent={agent}
|
||
onSave={(patch) => updateAgent.mutateAsync(patch)}
|
||
isSaving={isConfigSaving}
|
||
adapterModels={adapterModels}
|
||
onDirtyChange={onDirtyChange}
|
||
onSaveActionChange={onSaveActionChange}
|
||
onCancelActionChange={onCancelActionChange}
|
||
hideInlineSave
|
||
hidePromptTemplate={hidePromptTemplate}
|
||
hideInstructionsFile={hideInstructionsFile}
|
||
content={content === "runtime" ? "configuration" : "secrets"}
|
||
sectionLayout="cards"
|
||
environmentVariablesPlacement="configuration"
|
||
compactTestFeedback
|
||
sectionOrder={["identity", "adapter", "configuration", "environment", "environment-variables", "run-policy"]}
|
||
sectionTitles={{ adapter: "Adapter", configuration: "Configuration", identity: "Agent identity" }}
|
||
canConfigureProviderTrace={canConfigureProviderTrace}
|
||
/> : null}
|
||
|
||
|
||
{content === "permissions" ? <TrustPresetSection
|
||
permissions={agent.permissions}
|
||
disabled={updatePermissions.isPending}
|
||
companyId={companyId}
|
||
projectCandidates={(boundaryProjects ?? []).map((project) => ({
|
||
id: project.id,
|
||
label: project.name,
|
||
}))}
|
||
issueCandidates={(boundaryIssues ?? []).map((issue) => ({
|
||
id: issue.id,
|
||
label: `${issue.identifier ?? issue.id.slice(0, 8)} · ${issue.title}`,
|
||
}))}
|
||
candidatesLoading={boundaryProjectsLoading || boundaryIssuesLoading}
|
||
onChange={(nextPermissions) =>
|
||
updatePermissions.mutate({
|
||
canCreateAgents,
|
||
canCreateSkills,
|
||
canAssignTasks,
|
||
...buildPermissionsForTrustPreset(nextPermissions, nextPermissions.trustPreset === "low_trust_review" ? "low_trust_review" : "standard"),
|
||
})
|
||
}
|
||
/> : null}
|
||
|
||
{content === "permissions" ? <div>
|
||
<h3 className="text-sm font-medium mb-3">Permissions</h3>
|
||
<div className="border border-border rounded-lg p-4 space-y-4">
|
||
<div className="flex items-center justify-between gap-4 text-sm">
|
||
<div className="space-y-1">
|
||
<div>Can create new agents</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Lets this agent create or hire agents. This also grants task assignment authority.
|
||
</p>
|
||
</div>
|
||
<ToggleSwitch
|
||
checked={canCreateAgents}
|
||
onCheckedChange={() =>
|
||
updatePermissions.mutate({
|
||
canCreateAgents: !canCreateAgents,
|
||
canCreateSkills,
|
||
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
||
})
|
||
}
|
||
disabled={updatePermissions.isPending}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 text-sm">
|
||
<div className="space-y-1">
|
||
<div>Can create/import skills</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Lets this agent install, import, create, and scan organization skills without creating agents.
|
||
</p>
|
||
</div>
|
||
<ToggleSwitch
|
||
checked={canCreateSkills}
|
||
onCheckedChange={() =>
|
||
updatePermissions.mutate({
|
||
canCreateAgents,
|
||
canCreateSkills: !canCreateSkills,
|
||
canAssignTasks,
|
||
})
|
||
}
|
||
disabled={updatePermissions.isPending}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 text-sm">
|
||
<div className="space-y-1">
|
||
<div>Can assign tasks</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{taskAssignHint}
|
||
</p>
|
||
</div>
|
||
<ToggleSwitch
|
||
checked={canAssignTasks}
|
||
onCheckedChange={() =>
|
||
updatePermissions.mutate({
|
||
canCreateAgents,
|
||
canCreateSkills,
|
||
canAssignTasks: !canAssignTasks,
|
||
})
|
||
}
|
||
disabled={updatePermissions.isPending || taskAssignLocked}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---- Prompts Tab ---- */
|
||
|
||
export function PromptsTab({
|
||
agent,
|
||
companyId,
|
||
onDirtyChange,
|
||
onSaveActionChange,
|
||
onCancelActionChange,
|
||
onSavingChange,
|
||
showSaveNotice = true,
|
||
}: {
|
||
showSaveNotice?: boolean;
|
||
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 { isMobile } = useSidebar();
|
||
const [selectedFile, setSelectedFileState] = useState<string>("AGENTS.md");
|
||
const [instructionMode, setInstructionMode] = useState<"read" | "edit" | "raw">("read");
|
||
const [showFilePanel, setShowFilePanel] = useState(false);
|
||
const [draft, setDraft] = useState<string | null>(null);
|
||
const draftBaseRevisionRef = useRef<string | null | undefined>(undefined);
|
||
const draftBaseHashRef = useRef<string | null | undefined>(undefined);
|
||
const [candidateRunId, setCandidateRunId] = useState<string | null>(null);
|
||
const [readOnlyCandidateRunId, setReadOnlyCandidateRunId] = useState<string | null>(null);
|
||
const candidateAgentRef = useRef(agent.id);
|
||
candidateAgentRef.current = agent.id;
|
||
const [bundleDraft, setBundleDraft] = useState<{
|
||
mode: "managed" | "external";
|
||
rootPath: string;
|
||
entryFile: string;
|
||
} | null>(null);
|
||
const [newFilePath, setNewFilePath] = useState("");
|
||
const [showNewFileInput, setShowNewFileInput] = useState(false);
|
||
const [pendingFiles, setPendingFiles] = useState<string[]>([]);
|
||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||
const [filePanelWidth, setFilePanelWidth] = useState(260);
|
||
const [instructionPaneWidth, setInstructionPaneWidth] = useState<number | null>(null);
|
||
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);
|
||
// MDXEditor can normalize markdown and emit onChange while it mounts. Only
|
||
// treat editor output as a draft after a real interaction so merely opening
|
||
// an instructions file cannot mark the agent dirty.
|
||
const editorInteractedRef = useRef(false);
|
||
const markEditorInteracted = useCallback(() => {
|
||
editorInteractedRef.current = true;
|
||
}, []);
|
||
const setSelectedFile = useCallback((filePath: string) => {
|
||
editorInteractedRef.current = false;
|
||
draftBaseRevisionRef.current = undefined;
|
||
draftBaseHashRef.current = undefined;
|
||
setCandidateRunId(null);
|
||
setSelectedFileState(filePath);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
editorInteractedRef.current = false;
|
||
setSelectedFile("AGENTS.md");
|
||
setInstructionMode("read");
|
||
setShowFilePanel(false);
|
||
setDraft(null);
|
||
setReadOnlyCandidateRunId(null);
|
||
setBundleDraft(null);
|
||
setNewFilePath("");
|
||
setShowNewFileInput(false);
|
||
setPendingFiles([]);
|
||
setExpandedDirs(new Set());
|
||
setAwaitingRefresh(false);
|
||
lastFileVersionRef.current = null;
|
||
externalBundleRef.current = null;
|
||
}, [agent.id]);
|
||
|
||
const getCapabilities = useAdapterCapabilities();
|
||
const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle;
|
||
|
||
const { data: bundle, isLoading: bundleLoading } = useQuery({
|
||
queryKey: queryKeys.agents.instructionsBundle(agent.id),
|
||
queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
|
||
enabled: Boolean(companyId && isLocal),
|
||
refetchInterval: draft === null ? 5000 : false,
|
||
});
|
||
|
||
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, ...pendingFiles])]
|
||
: [currentEntryFile, ...pendingFiles],
|
||
[bundleMatchesDraft, currentEntryFile, fileOptions, pendingFiles],
|
||
);
|
||
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, error: fileError } = useQuery({
|
||
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
|
||
queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
|
||
enabled: Boolean(companyId && isLocal && selectedFileExists),
|
||
refetchInterval: draft === null ? 5000 : false,
|
||
});
|
||
|
||
const candidates = useQuery({
|
||
queryKey: queryKeys.agents.instructionCandidates(agent.id),
|
||
queryFn: () => agentsApi.instructionCandidates(agent.id, companyId),
|
||
enabled: Boolean(companyId && isLocal && currentMode === "managed"),
|
||
});
|
||
// Whole-folder failures belong to their run's diagnostics. They have no
|
||
// preserved edits to resolve and do not describe the current saved files.
|
||
const preservedCandidates = candidates.data?.filter((candidate) => candidate.contract !== "agent_files") ?? [];
|
||
const loadCandidate = useMutation({
|
||
mutationFn: async (candidate: AgentInstructionCandidate) => {
|
||
if (candidate.content === null) throw new Error("These instruction edits have not been retrieved yet.");
|
||
const file = await agentsApi.instructionsFile(agent.id, candidate.entryFile, companyId).catch((error) => {
|
||
if (error instanceof ApiError && error.status === 404 && candidate.baseRevisionId === null) return null;
|
||
throw error;
|
||
});
|
||
return { candidate, file, agentId: agent.id };
|
||
},
|
||
onSuccess: ({ candidate, file, agentId: requestedAgentId }) => {
|
||
if (candidateAgentRef.current !== requestedAgentId) return;
|
||
setSelectedFile(candidate.entryFile);
|
||
if (file) queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, candidate.entryFile), file);
|
||
draftBaseRevisionRef.current = file?.revision?.id ?? null;
|
||
setCandidateRunId(candidate.runId);
|
||
setDraft(candidate.content);
|
||
setInstructionMode("edit");
|
||
},
|
||
});
|
||
const resolveCandidate = useMutation({
|
||
mutationFn: async (data: { runId: string; content: string; baseRevisionId: string | null }) => ({
|
||
file: await agentsApi.resolveInstructionCandidate(agent.id, data.runId, { content: data.content, baseRevisionId: data.baseRevisionId }, companyId),
|
||
agentId: agent.id,
|
||
}),
|
||
onSuccess: ({ file, agentId: requestedAgentId }) => {
|
||
if (candidateAgentRef.current !== requestedAgentId) return;
|
||
setDraft(null);
|
||
setCandidateRunId(null);
|
||
draftBaseRevisionRef.current = undefined;
|
||
draftBaseHashRef.current = undefined;
|
||
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, file.path), file);
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionCandidates(agent.id) });
|
||
},
|
||
});
|
||
|
||
const refreshCandidateBase = useMutation({
|
||
mutationFn: async () => ({
|
||
file: await agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
|
||
agentId: agent.id, runId: candidateRunId,
|
||
}),
|
||
onSuccess: ({ file, agentId: requestedAgentId, runId }) => {
|
||
if (candidateAgentRef.current !== requestedAgentId || candidateRunId !== runId) return;
|
||
draftBaseRevisionRef.current = file.revision?.id ?? null;
|
||
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, file.path), file);
|
||
resolveCandidate.reset();
|
||
},
|
||
});
|
||
|
||
const updateBundle = useMutation({
|
||
mutationFn: (data: {
|
||
mode?: "managed" | "external";
|
||
rootPath?: string | null;
|
||
entryFile?: string;
|
||
clearLegacyPromptTemplate?: boolean;
|
||
}) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
|
||
onMutate: () => {
|
||
editorInteractedRef.current = false;
|
||
},
|
||
onSuccess: () => {
|
||
setAwaitingRefresh(true);
|
||
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; baseRevisionId?: string | null; baseHash?: string | null; clearLegacyPromptTemplate?: boolean }) =>
|
||
agentsApi.saveInstructionsFile(agent.id, data, companyId),
|
||
onMutate: () => {
|
||
editorInteractedRef.current = false;
|
||
},
|
||
onSuccess: (file, variables) => {
|
||
setDraft(null);
|
||
draftBaseRevisionRef.current = undefined;
|
||
draftBaseHashRef.current = undefined;
|
||
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, variables.path), file);
|
||
// Keep the selected file present while the refreshed bundle is in flight.
|
||
// Otherwise removing its pending placeholder briefly selects AGENTS.md.
|
||
queryClient.setQueryData<AgentInstructionsBundle>(queryKeys.agents.instructionsBundle(agent.id), previous => previous ? {
|
||
...previous, files: [...previous.files.filter(item => item.path !== file.path), {
|
||
path: file.path, size: file.size, language: file.language, markdown: file.markdown,
|
||
isEntryFile: file.isEntryFile, editable: file.editable, deprecated: file.deprecated,
|
||
virtual: file.virtual, binary: file.binary, contentHash: file.contentHash,
|
||
}],
|
||
} : previous);
|
||
setPendingFiles((prev) => prev.filter((f) => f !== variables.path));
|
||
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, bundle?.files.find(file => file.path === relativePath)?.contentHash),
|
||
onMutate: () => {
|
||
editorInteractedRef.current = false;
|
||
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 an organization 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) && selectedFile !== currentEntryFile && !pendingFiles.includes(selectedFile)) {
|
||
setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
|
||
}
|
||
}, [bundle, bundleMatchesDraft, currentEntryFile, pendingFiles, 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(() => {
|
||
if (isMobile) {
|
||
setInstructionPaneWidth(null);
|
||
return;
|
||
}
|
||
const element = containerRef.current;
|
||
if (!element) return;
|
||
|
||
const updateWidth = () => setInstructionPaneWidth(element.getBoundingClientRect().width);
|
||
updateWidth();
|
||
|
||
if (typeof ResizeObserver === "undefined") return;
|
||
const observer = new ResizeObserver((entries) => {
|
||
const entry = entries[0];
|
||
if (entry) setInstructionPaneWidth(entry.contentRect.width);
|
||
});
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [bundleLoading, isMobile, visibleFilePaths.length]);
|
||
|
||
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) {
|
||
if (draftBaseRevisionRef.current === undefined) 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 useMarkdownEditor = shouldUseMarkdownInstructionsEditor({
|
||
selectedFileExists,
|
||
selectedPath: selectedOrEntryFile,
|
||
detail: selectedFileDetail,
|
||
summary: selectedFileSummary,
|
||
});
|
||
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 || candidateRunId !== null;
|
||
const isSaving = updateBundle.isPending || saveFile.isPending || resolveCandidate.isPending || loadCandidate.isPending || refreshCandidateBase.isPending || deleteFile.isPending || awaitingRefresh;
|
||
|
||
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
|
||
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
|
||
|
||
useEffect(() => () => {
|
||
onSaveActionChange(null);
|
||
onCancelActionChange(null);
|
||
onDirtyChange(false);
|
||
onSavingChange(false);
|
||
}, [onCancelActionChange, onDirtyChange, onSaveActionChange, onSavingChange]);
|
||
|
||
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 (candidateRunId) {
|
||
await resolveCandidate.mutateAsync({ runId: candidateRunId, content: displayValue,
|
||
baseRevisionId: draftBaseRevisionRef.current ?? null });
|
||
} else if (fileDirty) {
|
||
await saveFile.mutateAsync({
|
||
path: selectedOrEntryFile,
|
||
content: displayValue,
|
||
...(bundle?.persistence === "agent_files" ? { baseHash: draftBaseHashRef.current !== undefined ? draftBaseHashRef.current : selectedFileDetail?.contentHash ?? null } : {}),
|
||
...(selectedOrEntryFile === currentEntryFile && currentMode === "managed" ? { baseRevisionId: draftBaseRevisionRef.current !== undefined ? draftBaseRevisionRef.current : selectedFileDetail?.revision?.id ?? null } : {}),
|
||
clearLegacyPromptTemplate: shouldClearLegacy,
|
||
});
|
||
}
|
||
};
|
||
void save().catch(() => undefined);
|
||
} : null);
|
||
}, [
|
||
bundle,
|
||
bundleDirty,
|
||
bundleDraft,
|
||
candidateRunId,
|
||
resolveCandidate,
|
||
displayValue,
|
||
fileDirty,
|
||
isDirty,
|
||
onSaveActionChange,
|
||
selectedFileDetail?.revision?.id,
|
||
currentEntryFile,
|
||
currentMode,
|
||
saveFile,
|
||
selectedOrEntryFile,
|
||
updateBundle,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
onCancelActionChange(isDirty ? () => {
|
||
draftBaseRevisionRef.current = undefined;
|
||
draftBaseHashRef.current = undefined;
|
||
setCandidateRunId(null);
|
||
resolveCandidate.reset();
|
||
setDraft(null);
|
||
if (bundle) {
|
||
setBundleDraft({
|
||
mode: persistedMode,
|
||
rootPath: persistedRootPath,
|
||
entryFile: bundle.entryFile,
|
||
});
|
||
}
|
||
} : null);
|
||
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath, resolveCandidate]);
|
||
|
||
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]);
|
||
|
||
const instructionsSideBySide =
|
||
!isMobile && instructionPaneWidth !== null && instructionPaneWidth >= filePanelWidth + 520;
|
||
|
||
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="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-900 dark:text-sky-100">
|
||
{warning}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{showSaveNotice && <p className="text-xs text-muted-foreground">
|
||
Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session.
|
||
</p>}
|
||
|
||
<Collapsible defaultOpen={currentMode === "external"}>
|
||
<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-6">
|
||
<TooltipProvider>
|
||
<div className="grid gap-x-6 gap-y-4 md:grid-cols-(--gtc-18)">
|
||
<label className="space-y-1.5 min-w-0">
|
||
<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>
|
||
<label className="space-y-1.5 min-w-0">
|
||
<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-xs text-muted-foreground pt-1.5">
|
||
<span className="min-w-0 truncate" title={currentRootPath || undefined}>{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.5">
|
||
<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
|
||
ref={containerRef}
|
||
className="grid min-w-0 gap-3"
|
||
style={
|
||
instructionsSideBySide
|
||
? { gridTemplateColumns: `${filePanelWidth}px 0.5rem minmax(0, 1fr)` }
|
||
: undefined
|
||
}
|
||
>
|
||
<div className={cn(
|
||
"min-w-0 w-full border border-border rounded-lg p-3 space-y-3",
|
||
isMobile && showFilePanel && "block",
|
||
isMobile && !showFilePanel && "hidden",
|
||
)}>
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-medium">Files</h4>
|
||
<div className="flex items-center gap-1">
|
||
{!showNewFileInput && (
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="outline"
|
||
className="h-7 w-7"
|
||
aria-label="Add agent file"
|
||
onClick={() => setShowNewFileInput(true)}
|
||
>
|
||
+
|
||
</Button>
|
||
)}
|
||
{isMobile && (
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-7 w-7"
|
||
onClick={() => setShowFilePanel(false)}
|
||
>
|
||
✕
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{showNewFileInput && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
value={newFilePath}
|
||
onChange={(event) => setNewFilePath(event.target.value)}
|
||
placeholder="TOOLS.md"
|
||
className="font-mono text-sm"
|
||
autoFocus
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Escape") {
|
||
setShowNewFileInput(false);
|
||
setNewFilePath("");
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="default"
|
||
className="flex-1"
|
||
disabled={!newFilePath.trim() || newFilePath.includes("..")}
|
||
onClick={() => {
|
||
const candidate = newFilePath.trim();
|
||
if (!candidate || candidate.includes("..")) return;
|
||
setPendingFiles((prev) => prev.includes(candidate) ? prev : [...prev, candidate]);
|
||
setSelectedFile(candidate);
|
||
setDraft("");
|
||
setNewFilePath("");
|
||
setShowNewFileInput(false);
|
||
}}
|
||
>
|
||
Create
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex-1"
|
||
onClick={() => {
|
||
setShowNewFileInput(false);
|
||
setNewFilePath("");
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<FileTree
|
||
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);
|
||
setInstructionMode("read");
|
||
if (!fileOptions.includes(filePath)) setDraft("");
|
||
if (isMobile) setShowFilePanel(false);
|
||
}}
|
||
onToggleCheck={() => {}}
|
||
showCheckboxes={false}
|
||
wrapLabels
|
||
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-800 dark:text-amber-200 px-1.5 py-0.5 text-(length:--text-nano) 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-(length:--text-nano) uppercase tracking-wide">
|
||
{file.isEntryFile ? "entry" : `${file.size}b`}
|
||
</span>
|
||
);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Draggable separator */}
|
||
{instructionsSideBySide && (
|
||
<div
|
||
className="w-1 cursor-col-resize rounded transition-colors hover:bg-border active:bg-primary/50"
|
||
onMouseDown={handleSeparatorDrag}
|
||
/>
|
||
)}
|
||
|
||
<div className={cn("min-w-0 w-full overflow-hidden border border-border rounded-lg p-4 space-y-3", isMobile && showFilePanel && "hidden")}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{isMobile && (
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="outline"
|
||
className="h-7 w-7 shrink-0"
|
||
onClick={() => setShowFilePanel(true)}
|
||
>
|
||
<FolderOpen className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
<div className="min-w-0">
|
||
<h4 className="text-sm font-medium font-mono truncate">{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>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center rounded-md border border-border p-0.5" role="group" aria-label="Instruction file view">
|
||
{(["read", "edit", "raw"] as const).map((mode) => (
|
||
<Button
|
||
key={mode}
|
||
type="button"
|
||
size="sm"
|
||
variant={instructionMode === mode ? "secondary" : "ghost"}
|
||
className="capitalize"
|
||
aria-pressed={instructionMode === mode}
|
||
onClick={() => setInstructionMode(mode)}
|
||
>
|
||
{mode}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
{!fileLoading && (
|
||
<CopyText
|
||
text={displayValue}
|
||
ariaLabel="Copy instructions file as markdown"
|
||
title="Copy as markdown"
|
||
copiedLabel="Copied"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</CopyText>
|
||
)}
|
||
{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>
|
||
</div>
|
||
|
||
{currentMode === "managed" && preservedCandidates.length > 0 && (
|
||
<div className="space-y-3">
|
||
<p className="text-sm font-medium">Preserved instruction edits</p>
|
||
<p className="text-sm text-muted-foreground">Older instruction-only sessions have edits to review.</p>
|
||
{preservedCandidates.map((candidate) => (
|
||
<div key={candidate.runId} className="flex flex-wrap items-center gap-3">
|
||
<span className="font-mono text-xs text-muted-foreground">{candidate.runId.slice(0, 8)}</span>
|
||
<span className="text-sm text-muted-foreground">{candidate.entryFile} · {formatDate(candidate.createdAt)}</span>
|
||
<Button type="button" variant="outline" size="sm"
|
||
disabled={candidate.content === null || (candidate.entryFile === currentEntryFile && (isDirty || isSaving))}
|
||
aria-expanded={candidate.entryFile !== currentEntryFile ? readOnlyCandidateRunId === candidate.runId : undefined}
|
||
onClick={() => {
|
||
if (candidate.entryFile !== currentEntryFile) {
|
||
setReadOnlyCandidateRunId((current) => current === candidate.runId ? null : candidate.runId);
|
||
} else {
|
||
loadCandidate.mutate(candidate);
|
||
}
|
||
}}>Review preserved edits</Button>
|
||
{candidate.errorMessage && <p className="text-sm text-muted-foreground">{candidate.errorMessage}</p>}
|
||
{candidate.entryFile !== currentEntryFile && <p className="text-sm text-muted-foreground">The instruction entry changed. These edits remain preserved for the original file.</p>}
|
||
{candidate.entryFile !== currentEntryFile && candidate.content !== null && readOnlyCandidateRunId === candidate.runId && (
|
||
<div role="region" aria-label={`Preserved edits for ${candidate.entryFile}`} className="w-full space-y-3">
|
||
<p className="text-sm text-muted-foreground">Read only: {candidate.entryFile}. To keep any of these edits in {currentEntryFile}, copy them and edit the current entry explicitly.</p>
|
||
<CopyText text={candidate.content} ariaLabel={`Copy preserved edits for ${candidate.entryFile}`}
|
||
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
|
||
<Copy className="h-3.5 w-3.5" />Copy preserved edits
|
||
</CopyText>
|
||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-sm">{candidate.content}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{candidateRunId && <div role="status" className="space-y-3">
|
||
<p className="text-sm text-muted-foreground">Reviewing preserved edits. Save to apply your resolved draft and close this preserved edit.</p>
|
||
<details><summary className="cursor-pointer text-sm text-muted-foreground">Compare current instructions</summary>
|
||
<pre className="whitespace-pre-wrap break-words rounded-md border border-border p-3 font-mono text-sm">{currentContent}</pre>
|
||
</details>
|
||
{draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null) && <p className="text-sm text-destructive">The current instructions changed after this draft was loaded. Refresh the current revision, compare the instructions, and save your resolved draft again.</p>}
|
||
{(resolveCandidate.error || draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null)) && <Button type="button" variant="outline" size="sm" disabled={isSaving} onClick={() => refreshCandidateBase.mutate()}>Refresh current revision</Button>}
|
||
</div>}
|
||
{(candidates.error || loadCandidate.error || resolveCandidate.error || refreshCandidateBase.error) && <p role="alert" className="text-sm text-destructive">{(candidates.error ?? loadCandidate.error ?? resolveCandidate.error ?? refreshCandidateBase.error)?.message} Your preserved edits remain available.</p>}
|
||
{(saveFile.error || fileError || updateBundle.error) && <p role="alert" className="text-sm text-destructive">{(saveFile.error ?? fileError ?? updateBundle.error)?.message} Your unsaved edits are retained.</p>}
|
||
{selectedFileDetail?.receipt?.materialization === "pending" && <p role="status" className="text-sm text-muted-foreground">Revision saved. The instruction file still needs to be rebuilt from the saved revision.</p>}
|
||
{selectedFileDetail?.revision && currentMode === "managed" && bundle?.persistence !== "agent_files" && <InstructionHistory
|
||
key={selectedOrEntryFile} agentId={agent.id} companyId={companyId} path={selectedOrEntryFile}
|
||
currentRevisionId={selectedFileDetail.revision.id} disabled={isDirty || isSaving}
|
||
onRestored={(file) => {
|
||
setDraft(null);
|
||
draftBaseRevisionRef.current = undefined;
|
||
draftBaseHashRef.current = undefined;
|
||
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile), file);
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
|
||
}}
|
||
/>}
|
||
{selectedFileDetail?.binary ? (
|
||
<div className="space-y-3 rounded-md border border-border p-4">
|
||
<p className="text-sm text-muted-foreground">This file is preserved with the agent directory. Download it to view its contents.</p>
|
||
<a className="text-sm text-primary underline" href={agentsApi.downloadInstructionsFile(agent.id, selectedOrEntryFile, companyId)} download>Download {selectedOrEntryFile}</a>
|
||
</div>
|
||
) : selectedFileExists && fileLoading && !selectedFileDetail ? (
|
||
<PromptEditorSkeleton />
|
||
) : instructionMode === "read" ? (
|
||
<div className="min-h-(--sz-420px) rounded-md border border-border bg-background p-4">
|
||
{displayValue.trim() ? (
|
||
useMarkdownEditor ? (
|
||
<MarkdownBody className="max-w-none text-sm leading-7 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||
{displayValue}
|
||
</MarkdownBody>
|
||
) : (
|
||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7">{displayValue}</pre>
|
||
)
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">This instruction file is empty.</p>
|
||
)}
|
||
</div>
|
||
) : instructionMode === "raw" ? (
|
||
<pre
|
||
data-testid="instructions-raw-source"
|
||
className="min-h-(--sz-420px) overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-4 font-mono text-sm leading-7"
|
||
>
|
||
{displayValue}
|
||
</pre>
|
||
) : useMarkdownEditor ? (
|
||
<div
|
||
onBeforeInputCapture={markEditorInteracted}
|
||
onDropCapture={markEditorInteracted}
|
||
onInput={markEditorInteracted}
|
||
onKeyDownCapture={markEditorInteracted}
|
||
onPasteCapture={markEditorInteracted}
|
||
onPointerDownCapture={markEditorInteracted}
|
||
>
|
||
<MarkdownEditor
|
||
key={selectedOrEntryFile}
|
||
value={displayValue}
|
||
onChange={(value) => {
|
||
if (!editorInteractedRef.current) return;
|
||
if (draftBaseRevisionRef.current === undefined) draftBaseRevisionRef.current = selectedFileDetail?.revision?.id ?? null;
|
||
if (draftBaseHashRef.current === undefined) draftBaseHashRef.current = selectedFileDetail?.contentHash ?? null;
|
||
setDraft(value ?? "");
|
||
}}
|
||
placeholder="# Agent instructions"
|
||
className="min-w-0 overflow-hidden"
|
||
contentClassName="min-h-(--sz-420px) max-w-full break-words text-sm leading-7"
|
||
imageUploadHandler={async (file) => {
|
||
const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
|
||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||
return asset.contentPath;
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<textarea
|
||
aria-label="Instruction file editor"
|
||
value={displayValue}
|
||
onChange={(event) => {
|
||
if (draftBaseRevisionRef.current === undefined) draftBaseRevisionRef.current = selectedFileDetail?.revision?.id ?? null;
|
||
if (draftBaseHashRef.current === undefined) draftBaseHashRef.current = selectedFileDetail?.contentHash ?? null;
|
||
setDraft(event.target.value);
|
||
}}
|
||
className="min-h-(--sz-420px) w-full min-w-0 rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
|
||
placeholder="File contents"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
</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-(--sz-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-(--gtc-19)">
|
||
<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-(--sz-420px) w-full" />
|
||
</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 ?? "";
|
||
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
|
||
|
||
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>
|
||
<Badge variant="ghost" className={cn(
|
||
"px-1.5 text-(length:--text-nano)",
|
||
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}
|
||
</Badge>
|
||
{sourceResolvedFold ? <SourceResolvedFoldBadge showIcon={false} className="shrink-0 text-(length:--text-nano) py-0" /> : null}
|
||
<span className="ml-auto text-(length:--text-micro) 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-(length:--text-micro) 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,
|
||
adapterConfig,
|
||
}: {
|
||
runs: HeartbeatRun[];
|
||
companyId: string;
|
||
agentId: string;
|
||
agentRouteId: string;
|
||
selectedRunId: string | null;
|
||
adapterType: string;
|
||
adapterConfig: Record<string, unknown>;
|
||
}) {
|
||
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} adapterConfig={adapterConfig} />
|
||
</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} adapterConfig={adapterConfig} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---- Run Detail (expanded) ---- */
|
||
|
||
export function AgentFileRunNotice({ resultJson }: { resultJson: HeartbeatRun["resultJson"] }) {
|
||
const save = asRecord(resultJson?.instructionSave);
|
||
const storageWarning = asNonEmptyString(save?.storageWarning);
|
||
const error = asNonEmptyString(save?.errorMessage);
|
||
const syncFailure = save?.contract === "agent_files" && save.state === "unavailable" && error;
|
||
// A quota rejection is already explained by the storage warning. A separate
|
||
// I/O failure must stay visible even when storage was full at run start.
|
||
const showSyncFailure = syncFailure && (!storageWarning || save?.errorCode !== "AGENT_FILES_LIMIT_EXCEEDED");
|
||
return <>
|
||
{storageWarning && <InlineBanner tone="warning" title="Agent storage warning" compact>{storageWarning}</InlineBanner>}
|
||
{showSyncFailure && <InlineBanner tone="warning" title="Agent file sync failed for this run" compact>{error}</InlineBanner>}
|
||
</>;
|
||
}
|
||
|
||
function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }: { run: HeartbeatRun; agentRouteId: string; adapterType: string; adapterConfig: Record<string, unknown> }) {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const { data: hydratedRun } = useQuery({
|
||
queryKey: queryKeys.runDetail(initialRun.id),
|
||
queryFn: () => heartbeatsApi.get(initialRun.id),
|
||
enabled: Boolean(initialRun.id),
|
||
refetchInterval: (query) => runDetailRefetchIntervalMs(
|
||
(query.state.data ?? initialRun).status,
|
||
),
|
||
});
|
||
const run = hydratedRun ?? initialRun;
|
||
const { data: boardAccess } = useQuery({
|
||
queryKey: queryKeys.access.currentBoardAccess,
|
||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||
retry: false,
|
||
});
|
||
const canUseProviderTrace =
|
||
boardAccess?.source === "local_implicit" ||
|
||
boardAccess?.isInstanceAdmin === true;
|
||
const { data: experimentalSettings } = useQuery({
|
||
queryKey: queryKeys.instance.experimentalSettings,
|
||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||
});
|
||
const paperclipDeveloperMode =
|
||
experimentalSettings?.enablePaperclipDeveloperMode === true;
|
||
const { data: providerTraceRows } = useQuery({
|
||
queryKey: queryKeys.providerTraceMetadata(run.companyId, [run.id]),
|
||
queryFn: () => heartbeatsApi.providerTraceMetadata(run.companyId, [run.id]),
|
||
enabled: canUseProviderTrace,
|
||
retry: false,
|
||
refetchInterval:
|
||
run.status === "running" || run.status === "queued" ? 3000 : false,
|
||
});
|
||
const providerTraceMetadata = providerTraceRows?.[0] ?? null;
|
||
const metrics = runMetrics(run);
|
||
const { data: userDirectory } = useQuery({
|
||
queryKey: queryKeys.access.companyUserDirectory(run.companyId),
|
||
queryFn: () => accessApi.listUserDirectory(run.companyId),
|
||
enabled: Boolean(run.companyId && run.responsibleUserId),
|
||
retry: false,
|
||
});
|
||
const responsibleUserName = useMemo(() => {
|
||
if (!run.responsibleUserId) return null;
|
||
const entry = userDirectory?.users.find(
|
||
(candidate) => candidate.principalId === run.responsibleUserId,
|
||
);
|
||
return entry?.user?.name ?? entry?.user?.email ?? null;
|
||
}, [run.responsibleUserId, userDirectory]);
|
||
const responsibleDenialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null;
|
||
const [sessionOpen, setSessionOpen] = useState(false);
|
||
const [inspectorOpen, setInspectorOpen] = 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(result.message ?? "Resume request was skipped.");
|
||
}
|
||
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")
|
||
&& run.execution?.phase !== "recovery_needed" && !run.execution?.successorRunId;
|
||
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 () => {
|
||
return agentsApi.retryFailedRun(run.agentId, run.id, run.companyId);
|
||
},
|
||
onSuccess: (newRun) => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
|
||
if (newRun.runId)
|
||
navigate(`/agents/${agentRouteId}/runs/${newRun.runId}`);
|
||
else if (newRun.issueId) navigate(`/issues/${newRun.issueId}`);
|
||
},
|
||
});
|
||
|
||
const rerunWithTrace = useMutation({
|
||
mutationFn: async () => {
|
||
const result = await agentsApi.wakeup(run.agentId, {
|
||
source: "on_demand",
|
||
triggerDetail: "manual",
|
||
reason: "rerun_with_provider_trace",
|
||
payload: retryPayload,
|
||
debug: { providerTrace: "raw" },
|
||
}, run.companyId);
|
||
if (!("id" in result)) {
|
||
throw new Error(result.message ?? "Trace re-run was skipped.");
|
||
}
|
||
return result;
|
||
},
|
||
onSuccess: (newRun) => {
|
||
setInspectorOpen(false);
|
||
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">
|
||
{/* Workspace-validation recovery: surfaces the recovery card when this run was declined over a
|
||
git workspace it could not validate, wired to the same reconcile / repair / re-issue /
|
||
break-glass handlers as the task detail page. */}
|
||
<RunWorkspaceRecoverySurface run={run} />
|
||
<AiConnectionPoolRunDetails context={run.contextSnapshot} />
|
||
{/* 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 flex-wrap">
|
||
<StatusBadge status={run.status} />
|
||
<ProviderTraceStatusBadge
|
||
trace={providerTraceMetadata}
|
||
requested={runRequestedProviderTrace(run.contextSnapshot)}
|
||
showOff
|
||
/>
|
||
{(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>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-xs h-6 px-2"
|
||
onClick={() => setInspectorOpen(true)}
|
||
>
|
||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||
Inspect run
|
||
</Button>
|
||
<HoneycombRunLink
|
||
runId={run.id}
|
||
enabled={paperclipDeveloperMode && canUseProviderTrace}
|
||
/>
|
||
{canUseProviderTrace && !["queued", "running"].includes(run.status) ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-xs h-6 px-2"
|
||
onClick={() => rerunWithTrace.mutate()}
|
||
disabled={rerunWithTrace.isPending}
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||
{rerunWithTrace.isPending ? "Starting…" : "Re-run with provider trace"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{/* Adapter type · provider · model */}
|
||
{(() => {
|
||
const displayProvider = metrics.provider
|
||
?? asNonEmptyString(adapterConfig?.provider);
|
||
const displayModel = metrics.model
|
||
?? asNonEmptyString(adapterConfig?.model);
|
||
if (!adapterType && !displayProvider && !displayModel) return null;
|
||
return (
|
||
<div className="text-(length:--text-micro) text-muted-foreground font-mono flex items-center gap-1.5 flex-wrap">
|
||
{adapterType && (
|
||
<span className="bg-muted rounded px-1.5 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide">{adapterType.replace(/_/g, " ")}</span>
|
||
)}
|
||
{displayProvider && displayModel && (
|
||
<span>{displayProvider}/{displayModel}</span>
|
||
)}
|
||
{!displayProvider && displayModel && (
|
||
<span>{displayModel}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
{run.responsibleUserId && (
|
||
<div
|
||
data-testid="run-detail-on-behalf-of"
|
||
className="text-xs text-muted-foreground"
|
||
>
|
||
On behalf of{" "}
|
||
<span className="text-foreground">
|
||
{responsibleUserName ?? responsibleUserLabel(null)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{Boolean(run.identityHistory?.length) && (
|
||
<details className="text-xs text-muted-foreground" data-testid="run-identity-history">
|
||
<summary className="cursor-pointer">GitHub identity history</summary>
|
||
<ol className="mt-2 space-y-2">
|
||
{run.identityHistory!.map((identity) => {
|
||
const person = userDirectory?.users.find((entry) => entry.principalId === identity.responsibleUserId);
|
||
return (
|
||
<li key={identity.id}>
|
||
<span className="text-foreground">{person?.user?.name ?? person?.user?.email ?? identity.responsibleUserId ?? "No responsible person"}</span>
|
||
{" · "}{identity.cause}{" · "}{identity.status}
|
||
{identity.github ? (
|
||
<span className="block">
|
||
{identity.github.login ? `@${identity.github.login} · ` : ""}
|
||
{identity.github.source ? `${identity.github.source} · ` : ""}
|
||
{identity.github.status}{identity.github.reason ? `: ${identity.github.reason}` : ""}
|
||
</span>
|
||
) : <span className="block">No GitHub operation recorded</span>}
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</details>
|
||
)}
|
||
{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"> → </span>}
|
||
{endTime}
|
||
</div>
|
||
<div className="text-(length:--text-micro) text-muted-foreground">
|
||
{relativeTime(run.startedAt!)}
|
||
{run.finishedAt && <> → {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>
|
||
)}
|
||
<AgentFileRunNotice resultJson={run.resultJson} />
|
||
{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>
|
||
)}
|
||
{responsibleDenialCode && (
|
||
<ResponsibleUserDenialNotice
|
||
code={responsibleDenialCode}
|
||
userName={responsibleUserName}
|
||
/>
|
||
)}
|
||
{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>
|
||
)}
|
||
<RunRetryDetails run={run} agentRouteId={agentRouteId} />
|
||
</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-(length:--text-micro) 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 tasks"}
|
||
</button>
|
||
{clearSessionsForTouchedIssues.isError && (
|
||
<p className="text-(length:--text-micro) 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">Tasks 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>
|
||
)}
|
||
|
||
{(() => {
|
||
const fold = readSourceResolvedWatchdogFold(run.resultJson);
|
||
if (!fold) return null;
|
||
if (run.status === "failed" || run.status === "timed_out") return null;
|
||
return <SourceResolvedFoldCallout fold={fold} finalizedAt={run.finishedAt} />;
|
||
})()}
|
||
|
||
{/* Log viewer */}
|
||
<LogViewer run={run} adapterType={adapterType} />
|
||
<ScrollToBottom />
|
||
<RunnerInspector
|
||
runId={run.id}
|
||
run={run}
|
||
open={inspectorOpen}
|
||
onOpenChange={setInspectorOpen}
|
||
onRerunWithTrace={
|
||
canUseProviderTrace && !["queued", "running"].includes(run.status)
|
||
? () => rerunWithTrace.mutate()
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---- Log Viewer ---- */
|
||
|
||
export function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||
const { visible } = usePageVisibility();
|
||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||
const [logLines, setLogLines] = useState<RunLogChunk[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||
const [logError, setLogError] = useState<string | null>(null);
|
||
const [logOffset, setLogOffsetState] = useState(0);
|
||
const logOffsetRef = useRef(0);
|
||
const setLogOffset = useCallback((next: number | ((previous: number) => number)) => {
|
||
logOffsetRef.current = typeof next === "function" ? next(logOffsetRef.current) : next;
|
||
setLogOffsetState(logOffsetRef.current);
|
||
}, []);
|
||
const logMergeRefs = useRef({ seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() });
|
||
const [hasMoreLog, setHasMoreLog] = useState(false);
|
||
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
|
||
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 seenProgressLogLineKeysRef = useRef<Set<string>>(new Set());
|
||
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 shouldPollShellLog = shouldPollRunShellLog(run.status);
|
||
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 appendLogLines(incoming: RunLogChunk[]) {
|
||
setLogLines((previous) => mergeRunLogChunks(run.id, previous, incoming.map((line) => ({
|
||
...line, dedupeKey: `log:${run.id}:${line.ts}:${line.stream}:${line.chunk}`,
|
||
})), logMergeRefs.current, isLive ? MAX_LIVE_LOG_LINES : Number.POSITIVE_INFINITY).chunks);
|
||
}
|
||
|
||
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: RunLogChunk[] = [];
|
||
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; seq?: 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, seq: readChunkSeq(raw.seq) });
|
||
} catch {
|
||
// ignore malformed lines
|
||
}
|
||
}
|
||
|
||
if (parsed.length > 0) {
|
||
// Live runs stream forever, so cap the retained tail. Terminated runs are
|
||
// paginated by the user via "Load more log" and keep their full history.
|
||
appendLogLines(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]);
|
||
|
||
// Reset only when the log source changes, never when visibility changes.
|
||
useEffect(() => {
|
||
pendingLogLineRef.current = "";
|
||
logMergeRefs.current = { seenChunkKeys: new Set(), trimmedSeqFloorByRun: new Map() };
|
||
seenProgressLogLineKeysRef.current = new Set();
|
||
setLogLines([]);
|
||
setLogOffset(0);
|
||
setHasMoreLog(false);
|
||
setLoadingMoreLog(false);
|
||
setLogError(null);
|
||
}, [run.id, run.logRef, setLogOffset]);
|
||
|
||
// Fetch persisted shell log, retaining partial rows and offsets across hides.
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
let cancelled = false;
|
||
const offset = logOffsetRef.current;
|
||
if (!run.logRef && !shouldPollShellLog) {
|
||
setLogLoading(false);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
setLogLoading(true);
|
||
const load = async () => {
|
||
try {
|
||
const result = await heartbeatsApi.log(run.id, offset, RUN_LOG_PAGE_BYTES);
|
||
if (cancelled) return;
|
||
appendLogContent(result.content, result.nextOffset === undefined);
|
||
const next = result.nextOffset ?? offset + result.content.length;
|
||
setLogOffset(next);
|
||
setHasMoreLog(!shouldPollShellLog && result.nextOffset !== undefined);
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
if (shouldPollShellLog && 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;
|
||
};
|
||
}, [visible, run.id, run.logRef, run.logBytes, shouldPollShellLog]);
|
||
|
||
async function loadMorePersistedLog() {
|
||
if (loadingMoreLog || !hasMoreLog) return;
|
||
setLoadingMoreLog(true);
|
||
setLogError(null);
|
||
try {
|
||
const result = await heartbeatsApi.log(run.id, logOffset, RUN_LOG_PAGE_BYTES);
|
||
appendLogContent(result.content, result.nextOffset === undefined);
|
||
const next = result.nextOffset ?? logOffset + result.content.length;
|
||
setLogOffset(next);
|
||
setHasMoreLog(result.nextOffset !== undefined);
|
||
} catch (err) {
|
||
setLogError(err instanceof Error ? err.message : "Failed to load more run log");
|
||
} finally {
|
||
setLoadingMoreLog(false);
|
||
}
|
||
}
|
||
|
||
// Poll for live updates
|
||
useEffect(() => {
|
||
if (!visible || !isLive || isStreamingConnected) return;
|
||
let pending = false;
|
||
let cancelled = false;
|
||
const interval = setInterval(async () => {
|
||
if (pending || cancelled || !getPageVisibility().visible) return;
|
||
pending = true;
|
||
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
|
||
try {
|
||
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
|
||
if (cancelled) return;
|
||
if (newEvents.length > 0) {
|
||
setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS));
|
||
}
|
||
} catch {
|
||
// ignore polling errors
|
||
} finally {
|
||
pending = false;
|
||
}
|
||
}, 2000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(interval);
|
||
};
|
||
}, [visible, run.id, isLive, isStreamingConnected, events]);
|
||
|
||
// Poll shell log for running runs
|
||
useEffect(() => {
|
||
if (!visible || !shouldPollShellLog || isStreamingConnected) return;
|
||
let pending = false;
|
||
let cancelled = false;
|
||
const interval = setInterval(async () => {
|
||
if (pending || cancelled || !getPageVisibility().visible) return;
|
||
pending = true;
|
||
try {
|
||
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
|
||
if (cancelled) return;
|
||
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
|
||
} finally {
|
||
pending = false;
|
||
}
|
||
}, 2000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(interval);
|
||
};
|
||
}, [visible, run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
|
||
|
||
// Stream live updates from websocket (primary path for running runs).
|
||
useEffect(() => {
|
||
if (!visible || !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 url = buildSameOriginWebSocketUrl(
|
||
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
|
||
);
|
||
socket = tryCreateWebSocket(url);
|
||
if (!socket) {
|
||
scheduleReconnect();
|
||
return;
|
||
}
|
||
|
||
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;
|
||
appendLogLines([{ ts, stream, chunk, seq: readChunkSeq(payload.seq) }]);
|
||
return;
|
||
}
|
||
|
||
if (event.type === "heartbeat.run.progress") {
|
||
const line = buildHeartbeatProgressLogLine(payload, event.createdAt);
|
||
if (!line) return;
|
||
const key = heartbeatProgressLogLineKey(line);
|
||
if (seenProgressLogLineKeysRef.current.has(key)) return;
|
||
seenProgressLogLineKeysRef.current.add(key);
|
||
appendLogLines([line]);
|
||
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 appendCapped(prev, [liveEvent], MAX_LIVE_EVENTS);
|
||
});
|
||
};
|
||
|
||
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");
|
||
}
|
||
};
|
||
}, [visible, isLive, run.companyId, run.id, run.agentId]);
|
||
|
||
const censorUsernameInLogs = useQuery({
|
||
queryKey: queryKeys.instance.generalSettings,
|
||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||
}).data?.censorUsernameInLogs === true;
|
||
|
||
const adapterInvokePayload = useMemo(() => {
|
||
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
||
return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
|
||
}, [censorUsernameInLogs, events]);
|
||
|
||
// NOTE: adapter is NOT memoized because external adapters replace their
|
||
// parseStdoutLine asynchronously after dynamic parser loading. Memoizing
|
||
// on adapterType alone would stale the transcript with the fallback parser.
|
||
// We subscribe to adapter registry changes to force transcript recomputation.
|
||
const [parserTick, setParserTick] = useState(0);
|
||
const adapter = getUIAdapter(adapterType);
|
||
|
||
useEffect(() => {
|
||
return onAdapterChange(() => setParserTick((t) => t + 1));
|
||
}, []);
|
||
|
||
const transcript = useMemo(
|
||
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
|
||
[adapter, censorUsernameInLogs, logLines, parserTick],
|
||
);
|
||
const toolDecisionLookup = useQuery({
|
||
queryKey: queryKeys.tools.runDecisions(run.companyId, run.id),
|
||
queryFn: () => toolsApi.getRunDecisionLookup(run.companyId, run.id),
|
||
enabled: Boolean(run.companyId && run.id),
|
||
refetchInterval: isLive ? 3000 : false,
|
||
staleTime: isLive ? 1000 : 30_000,
|
||
});
|
||
|
||
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}
|
||
censorUsernameInLogs={censorUsernameInLogs}
|
||
/>
|
||
{adapterInvokePayload && (
|
||
<RunInvocationCard payload={adapterInvokePayload} censorUsernameInLogs={censorUsernameInLogs} />
|
||
)}
|
||
|
||
<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-(length:--text-micro) 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-blue-600 dark:text-blue-400">
|
||
<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>
|
||
Live
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="max-h-(--sz-38rem) overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
|
||
<RunTranscriptView
|
||
entries={transcript}
|
||
toolDecisions={toolDecisionLookup.data?.decisions ?? []}
|
||
mode={transcriptMode}
|
||
streaming={isLive}
|
||
limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined}
|
||
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
|
||
/>
|
||
{hasMoreLog && (
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
onClick={loadMorePersistedLog}
|
||
disabled={loadingMoreLog}
|
||
>
|
||
{loadingMoreLog ? "Loading..." : "Load more log"}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
Showing the first {Math.round(logOffset / 1024).toLocaleString("en-US")} KB
|
||
{typeof run.logBytes === "number" && run.logBytes > 0
|
||
? ` of ${Math.round(run.logBytes / 1024).toLocaleString("en-US")} KB`
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{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>
|
||
{redactPathText(run.error, censorUsernameInLogs)}
|
||
</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">
|
||
{redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
|
||
</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(redactPathValue(run.resultJson, censorUsernameInLogs), 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">
|
||
{redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
|
||
</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
|
||
? redactPathText(evt.message, censorUsernameInLogs)
|
||
: evt.payload
|
||
? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs))
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---- Keys Tab ---- */
|
||
|
||
export function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
|
||
const queryClient = useQueryClient();
|
||
const { pushToast } = useToastActions();
|
||
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;
|
||
void copyTextToClipboard(newToken)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
})
|
||
.catch(() => {
|
||
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
||
});
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|