diff --git a/ui/src/App.activity-routing.test.tsx b/ui/src/App.activity-routing.test.tsx index 88c1e3c8c7..c1adf64c58 100644 --- a/ui/src/App.activity-routing.test.tsx +++ b/ui/src/App.activity-routing.test.tsx @@ -69,6 +69,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({ }, })); +vi.mock("./pages/audit/AuditHub", () => ({ + AuditHub: ({ section }: { section: string }) => { + const location = useLocation(); + return
{`AUDIT_${section.toUpperCase()}@${location.pathname}${location.search}`}
; + }, +})); + vi.mock("./pages/Issues", () => ({ Issues: () => { const location = useLocation(); @@ -152,6 +159,18 @@ describe("App Activity routing (PAP-16302)", () => { flushSync(() => root.unmount()); }); + it("serves organization and entity-scoped run history beneath Activity", async () => { + const root = renderAppAt( + container, + "/PAP/activity/runs?entityType=routine&entityId=routine-1", + ); + await waitForRoute( + container, + "AUDIT_RUNS@/PAP/activity/runs?entityType=routine&entityId=routine-1", + ); + flushSync(() => root.unmount()); + }); + it("redirects /:company/audit to Activity with the agent-actions mode preset", async () => { const root = renderAppAt(container, "/PAP/audit"); await waitForRoute(container, "ACTIVITY_PAGE@/PAP/activity?mode=agents"); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 803188021c..147b6a4645 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -50,6 +50,7 @@ import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { CompanyActivity } from "./pages/audit/CompanyActivity"; +import { AuditHub } from "./pages/audit/AuditHub"; import { Inbox } from "./pages/Inbox"; import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { DecisionQueuePage } from "./pages/DecisionQueuePage"; @@ -325,6 +326,10 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> {/* `/audit` merged into the single Activity page (PAP-16302). Existing deep links keep working, preset to the agent-actions scope. */} } /> diff --git a/ui/src/components/CollectionToolbar.test.tsx b/ui/src/components/CollectionToolbar.test.tsx new file mode 100644 index 0000000000..61866ed7dd --- /dev/null +++ b/ui/src/components/CollectionToolbar.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import { CollectionToolbar } from "./CollectionToolbar"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CollectionToolbar", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + afterEach(() => { + if (root) flushSync(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + }); + + it("keeps collection controls in stable semantic slots", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + flushSync(() => { + root?.render( + Mine} + search={} + controls={} + actions={} + feedback={Status: active} + />, + ); + }); + + const toolbar = container.querySelector('[role="toolbar"]'); + expect(toolbar?.getAttribute("aria-label")).toBe("Task list controls"); + expect(toolbar?.querySelector('[data-slot="collection-toolbar-context"]')?.textContent).toBe("Mine"); + expect(toolbar?.querySelector('[data-slot="collection-toolbar-search"] input')).not.toBeNull(); + expect(toolbar?.querySelector('[data-slot="collection-toolbar-controls"]')?.textContent).toBe("Filter"); + expect(toolbar?.querySelector('[data-slot="collection-toolbar-actions"]')?.textContent).toBe("New task"); + expect(toolbar?.querySelector('[data-slot="collection-toolbar-feedback"]')?.textContent).toBe("Status: active"); + }); + + it("omits empty optional slots", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + flushSync(() => root?.render(Search} />)); + + expect(container.querySelector('[data-slot="collection-toolbar-context"]')).toBeNull(); + expect(container.querySelector('[data-slot="collection-toolbar-controls"]')).toBeNull(); + expect(container.querySelector('[data-slot="collection-toolbar-actions"]')).toBeNull(); + expect(container.querySelector('[data-slot="collection-toolbar-feedback"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/CollectionToolbar.tsx b/ui/src/components/CollectionToolbar.tsx new file mode 100644 index 0000000000..d38369dd05 --- /dev/null +++ b/ui/src/components/CollectionToolbar.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export interface CollectionToolbarProps { + /** Primary context such as tabs, a view title, or a result count. */ + context?: ReactNode; + /** The collection's canonical search control. */ + search?: ReactNode; + /** Filter, sort, column, density, and view controls. */ + controls?: ReactNode; + /** Collection-specific actions such as create or bulk operations. */ + actions?: ReactNode; + /** Optional second row for active-filter chips or selection feedback. */ + feedback?: ReactNode; + className?: string; + ariaLabel?: string; +} + +/** + * Presentation-only shell for list and board controls. + * + * State, queries, and control behavior stay with the consuming surface. Keeping + * this component slot-based lets Inbox, Tasks, routine runs, and scoped task + * lists share geometry without coupling their data models. + */ +export function CollectionToolbar({ + context, + search, + controls, + actions, + feedback, + className, + ariaLabel = "Collection controls", +}: CollectionToolbarProps) { + return ( +
+
+ {context ? ( +
+ {context} +
+ ) : null} + {search ? ( +
+ {search} +
+ ) : null} + {(controls || actions) ? ( +
+ {controls ? ( +
+ {controls} +
+ ) : null} + {actions ? ( +
+ {actions} +
+ ) : null} +
+ ) : null} +
+ {feedback ? ( +
+ {feedback} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index b31dfeca8f..a319063502 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -122,6 +122,159 @@ describe("IssueRow", () => { }); }); + it("uses stable canonical identifier and timestamp columns at the trailing edge", () => { + const root = createRoot(container); + + act(() => { + root.render( + Live} + actions={} + trailingMeta="Updated now" + />, + ); + }); + + const row = container.querySelector('[data-slot="task-row"]'); + const leading = row?.querySelector('[data-slot="task-row-leading"]'); + const title = row?.querySelector('[data-slot="task-row-title"]'); + const metadata = row?.querySelector('[data-slot="task-row-metadata"]'); + const identifier = row?.querySelector('[data-slot="task-row-identifier"]'); + const timestamp = row?.querySelector('[data-slot="task-row-timestamp"]'); + const actions = row?.querySelector('[data-slot="task-row-actions"]'); + const link = row?.querySelector('[data-inbox-issue-link]'); + + expect(leading?.querySelector("svg")).not.toBeNull(); + expect(title?.textContent).toContain("Canonical task"); + expect(metadata?.textContent).toBe("Live"); + expect(identifier?.textContent).toBe("PAP-42"); + expect(timestamp?.textContent).toBe("Updated now"); + expect(actions?.textContent).toBe("More"); + expect(identifier?.className).toContain("w-20"); + expect(timestamp?.className).toContain("w-24"); + if (!link || !metadata || !identifier || !timestamp || !actions) throw new Error("Expected canonical task row slots"); + expect(link.contains(actions)).toBe(false); + expect(metadata.compareDocumentPosition(actions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(actions.compareDocumentPosition(identifier) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(timestamp.nextElementSibling).toBeNull(); + + act(() => root.unmount()); + }); + + it("keeps the canonical archive action within the shared task-row height", () => { + const root = createRoot(container); + + act(() => { + root.render( + undefined} + />, + ); + }); + + const archiveButton = container.querySelector('button[aria-label="Archive"]'); + expect(archiveButton?.className).toContain("h-5"); + expect(archiveButton?.className).toContain("py-0"); + expect(archiveButton?.className).not.toContain("py-1"); + + act(() => root.unmount()); + }); + + it("preserves the legacy archive action density", () => { + const root = createRoot(container); + + act(() => { + root.render( undefined} />); + }); + + const archiveButton = container.querySelector('button[aria-label="Archive"]'); + expect(archiveButton?.className).toContain("py-1"); + expect(archiveButton?.className).not.toContain("h-5"); + + act(() => root.unmount()); + }); + + it("emphasizes unread canonical titles and overlays the accessible mark-read control", () => { + const root = createRoot(container); + const onMarkRead = vi.fn(); + act(() => { + root.render( + , + ); + }); + + const row = container.querySelector('[data-slot="task-row"]'); + const title = row?.querySelector('[data-slot="task-row-title"]'); + const unreadSlot = row?.querySelector('[data-testid="issue-row-unread-slot"]'); + const markReadButton = unreadSlot?.querySelector('button[aria-label="Mark as read"]'); + expect(row?.getAttribute("data-unread")).toBe("true"); + expect(title?.className).toContain("font-semibold"); + expect(unreadSlot).not.toBeNull(); + expect(unreadSlot?.className).toContain("absolute"); + expect(markReadButton).not.toBeNull(); + expect(markReadButton?.closest("a")).toBeNull(); + + act(() => markReadButton?.click()); + expect(onMarkRead).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); + + it("keeps canonical leading geometry independent of unread state", () => { + const root = createRoot(container); + act(() => { + root.render( + <> + + + , + ); + }); + + const rows = Array.from(container.querySelectorAll('[data-slot="task-row"]')); + const unreadSlot = rows[0]?.querySelector('[data-testid="issue-row-unread-slot"]'); + expect(rows).toHaveLength(2); + expect(rows[0]?.className).toBe(rows[1]?.className); + expect(unreadSlot).not.toBeNull(); + expect(unreadSlot?.className).toContain("absolute"); + expect(unreadSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull(); + expect(rows[1]?.querySelector('[data-testid="issue-row-unread-slot"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("preserves task-tree indentation slots in the canonical layout", () => { + const root = createRoot(container); + act(() => { + root.render( + Expand} + />, + ); + }); + + expect(container.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(2); + expect(container.querySelector('[data-slot="task-row-leading"]')?.textContent).toContain("Expand"); + for (const connector of container.querySelectorAll('[data-slot="task-row-tree-connector"]')) { + expect(connector.className).toContain("left-7"); + } + act(() => root.unmount()); + }); + it("keeps editable row controls keyboard-accessible and outside the navigation link", () => { const root = createRoot(container); @@ -509,6 +662,19 @@ describe("IssueRow", () => { }); }); + it("never renders a horizontal divider in canonical task presentation", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const row = container.querySelector('[data-slot="task-row"]'); + expect(row?.className).not.toContain("border-b"); + + act(() => root.unmount()); + }); + it("keeps the hover wash on the row root while the overlay link stays a bare positioning layer", () => { const root = createRoot(container); @@ -620,6 +786,34 @@ describe("IssueRow", () => { expect(label).not.toContain("next try"); }); + it.each(["task", "legacy"] as const)( + "places the recovery chip immediately after the title in %s list rows", + (presentation) => { + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + const titleCluster = container.querySelector('[data-slot="task-row-title-cluster"]'); + const title = titleCluster?.querySelector('[data-slot="task-row-title"]'); + const chip = titleCluster?.querySelector('[data-testid="issue-row-recovery-indicator"]'); + expect(titleCluster).not.toBeNull(); + expect(title).not.toBeNull(); + expect(chip).not.toBeNull(); + if (!title || !chip) throw new Error("Expected the title and recovery chip"); + expect(title.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + act(() => { + root.unmount(); + }); + }, + ); + it("stays calm when the overdue attempt is a verified live run", () => { const chip = renderChip( recoveryIssue(at(-5 * 60_000), { diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index b383670cc3..4f5f01c52d 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -24,12 +24,25 @@ import { hasAssignedBacklogBlocker } from "../lib/issue-blockers"; import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; import { Badge } from "@/components/ui/badge"; -type UnreadState = "hidden" | "visible" | "fading"; +export type IssueRowUnreadState = "hidden" | "visible" | "fading"; +export type IssueRowPresentation = "legacy" | "task"; -interface IssueRowProps { +export interface IssueRowProps { issue: Issue; issueLinkState?: unknown; selected?: boolean; + /** Opt-in canonical collection layout. Legacy remains the default until each surface migrates. */ + presentation?: IssueRowPresentation; + /** Interactive disclosure or selection control before the canonical status glyph. */ + leadingControl?: ReactNode; + /** Optional status override; defaults to the task's shared StatusIcon. */ + statusSlot?: ReactNode; + /** Stable metadata slot before the task's optional collection columns. */ + metadata?: ReactNode; + /** Stable interactive action slot before the identifier and timestamp columns. */ + actions?: ReactNode; + /** Controls the canonical trailing identifier without affecting legacy layouts. */ + showIdentifier?: boolean; mobileLeading?: ReactNode; desktopMetaLeading?: ReactNode; desktopLeadingSpacer?: boolean; @@ -47,7 +60,7 @@ interface IssueRowProps { checklistCurrentStep?: boolean; checklistDependencyChips?: ReactNode; checklistRowId?: string; - unreadState?: UnreadState | null; + unreadState?: IssueRowUnreadState | null; onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; @@ -57,21 +70,22 @@ interface IssueRowProps { /** Ancestor levels; renders that many vertical tree-guide slots (desktop). */ treeGuides?: number; /** - * This row has its own collapse chevron sitting in the innermost guide - * column (a nested parent). Breaks the guide line there so the chevron is - * not crossed out by it. + * This nested row has its own collapse chevron aligned with the innermost + * guide. Breaks the guide line there so the chevron is not crossed out. */ chevronInGuide?: boolean; - /** Opt in to a bottom divider on this row (default off; used by views that intentionally keep separators). */ + /** Legacy-only opt in to a bottom divider; canonical task rows stay divider-free. */ showDivider?: boolean; } export function InboxArchiveButton({ onArchive, disabled, + compact = false, }: { onArchive: () => void; disabled?: boolean; + compact?: boolean; }) { return ( + + {recentRuns.length === 0 ? ( +

+ No runs yet. Run the routine now or wait for its schedule. +

+ ) : ( +
+ {recentRuns.map((run) => run.linkedIssue ? ( + + + {formatRoutineTimestamp(run.triggeredAt)} + + )} + /> + ) : ( +
+ + {run.trigger?.label ?? "Routine run"} + {formatRoutineTimestamp(run.triggeredAt)} +
+ ))} +
+ )} + + + + ); +} diff --git a/ui/src/components/onboarding/PillGuy.tsx b/ui/src/components/onboarding/PillGuy.tsx index 588cfac87f..73a01b44cc 100644 --- a/ui/src/components/onboarding/PillGuy.tsx +++ b/ui/src/components/onboarding/PillGuy.tsx @@ -34,8 +34,8 @@ function DormantPill() { {/* Closed eyes: the same rounded rects as the open pair, flattened. */} - - + + - - + + @@ -57,9 +57,9 @@ function AlivePill() { return ( - - - + + + - - + + diff --git a/ui/src/components/routine-sections/editable-sections.production.tsx b/ui/src/components/routine-sections/editable-sections.production.tsx new file mode 100644 index 0000000000..49e79c2522 --- /dev/null +++ b/ui/src/components/routine-sections/editable-sections.production.tsx @@ -0,0 +1,859 @@ +import { useEffect, useMemo, useState } from "react"; +import { + ArrowRight, + Braces, + Clock3, + Edit3, + KeyRound, + Play, + Plus, + X, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RadioCardGroup } from "@/components/ui/radio-card"; +import { cn } from "@/lib/utils"; +import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires"; +import { timeAgo } from "../../lib/timeAgo"; +import { EmptyState } from "../EmptyState"; +import { InlineEntitySelector } from "../InlineEntitySelector"; +import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations"; +import { AgentIcon } from "../AgentIconPicker"; +import { MarkdownEditor } from "../MarkdownEditor"; +import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor"; +import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; +import { RoutineTriggerCard } from "../RoutineTriggerCard"; +import { EnvironmentVariablesEditor } from "../environment-variables-editor"; +import { createDefaultNewTrigger, useRoutineDetail } from "./context"; +import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared"; + +const concurrencyPolicyOptions = [ + { + value: "coalesce_if_active", + title: "Coalesce if active", + description: "Keep one follow-up run queued while an active run is still working.", + }, + { + value: "always_enqueue", + title: "Always enqueue", + description: "Queue every trigger occurrence, even if several runs stack up.", + }, + { + value: "skip_if_active", + title: "Skip if active", + description: "Drop overlapping trigger occurrences while the routine is already active.", + }, +]; + +const catchUpPolicyOptions = [ + { + value: "skip_missed", + title: "Skip missed", + description: "Ignore schedule windows that were missed while paused.", + }, + { + value: "enqueue_missed_with_cap", + title: "Enqueue missed with cap", + description: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.", + }, +]; + +const activityGatePolicyOptions = [ + { + value: "always", + title: "Run on every scheduled tick", + description: "Fire on the schedule no matter what — the default behavior.", + }, + { + value: "require_external_activity", + title: "Skip when there's been no activity since the last run", + description: + "On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.", + }, +]; + +const activityGateScopeOptions = [ + { + value: "company", + title: "Company-wide", + description: "Any activity across the company counts as a reason to run.", + }, + { + value: "project", + title: "This project", + description: "Only activity in the routine's project counts as a reason to run.", + }, +]; + +const triggerKinds = ["schedule", "webhook"]; +const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; +const signingModeDescriptions: Record = { + bearer: "Expect a shared bearer token in the Authorization header.", + hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.", + github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).", + none: "No authentication — the webhook URL itself acts as a shared secret.", +}; +const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); + +export function OverviewSection({ + defaultDescriptionAnnotationsOpen = false, +}: { + defaultDescriptionAnnotationsOpen?: boolean; +} = {}) { + const ctx = useRoutineDetail(); + const { + routine, + editDraft, + setEditDraft, + assigneeOptions, + projectOptions, + recentAssigneeIds, + recentProjectIds, + agentById, + projectById, + currentAssignee, + currentProject, + mentionOptions, + assigneeSelectorRef, + projectSelectorRef, + descriptionEditorRef, + routineRuns, + activity, + saveRoutine, + saveConflict, + isSectionDirty, + navigateToSection, + } = ctx; + const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen); + + const activeTriggers = routine.triggers.length; + const nextFire = useMemo(() => { + const upcoming = routine.triggers + .filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt) + .map((trigger) => new Date(trigger.nextRunAt as Date)) + .sort((a, b) => a.getTime() - b.getTime())[0]; + return upcoming ? upcoming.toLocaleString() : null; + }, [routine.triggers]); + const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0; + const lastRun = (routineRuns ?? [])[0] ?? null; + const recentActivity = (activity ?? []).slice(0, 5); + + return ( +
+ {/* Assignment row */} +
+
+ For + + setEditDraft((current) => ({ ...current, assigneeAgentId })) + } + onConfirm={() => { + if (editDraft.projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } + }} + renderTriggerValue={(option) => + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( + {option.label} + ) + ) : ( + Responsible + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const assignee = agentById.get(option.id); + return ( + <> + {assignee ? ( + + ) : null} + {option.label} + + ); + }} + /> + in + setEditDraft((current) => ({ ...current, projectId }))} + onConfirm={() => descriptionEditorRef.current?.focus()} + renderTriggerValue={(option) => + option && currentProject ? ( + <> + + {option.label} + + ) : ( + Project + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const project = projectById.get(option.id); + return ( + <> + + {option.label} + + ); + }} + /> +
+
+ + {!routine.assigneeAgentId ? ( +
+ Default agent required. This routine can stay as a draft and still run manually, but + automation stays paused until you assign a default agent. +
+ ) : null} + + {/* Instructions */} +
+
+ {routine.descriptionDocument ? ( + setDescriptionAnnotationsOpen((open) => !open)} + /> + ) : null} +
+ {routine.descriptionDocument ? ( + + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-(--sz-120px) text-sm leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + + ) : ( + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-(--sz-120px) text-sm leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + )} +
+ + {/* Variables peek */} +
+ + setEditDraft((current) => ({ ...current, variables }))} + /> +
+ + {/* Summary cards */} +
+ navigateToSection("triggers")} + ariaLabel={`${activeTriggers} triggers. Open triggers.`} + /> + navigateToSection("secrets")} + ariaLabel={`${boundSecrets} secrets bound. Open secrets.`} + /> + navigateToSection("runs")} + ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."} + /> +
+ + {/* Recent activity */} +
+

+ Recent activity +

+ {recentActivity.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {recentActivity.map((event) => ( +
+ + {event.action} + + + {event.details && Object.keys(event.details).length > 0 + ? Object.keys(event.details).slice(0, 3).join(" · ") + : ""} + + {timeAgo(event.createdAt)} +
+ ))} + +
+ )} +
+
+ ); +} + +function SummaryCard({ + icon: Icon, + label, + value, + hint, + to, + ariaLabel, +}: { + icon: typeof Clock3; + label: string; + value: string; + hint: string; + to: () => void; + ariaLabel: string; +}) { + return ( + + ); +} + +export function TriggersSection() { + const ctx = useRoutineDetail(); + const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx; + const [addOpen, setAddOpen] = useState(false); + const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true); + const newScheduleValidation = useMemo( + () => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null, + [newTrigger.cronExpression, newTrigger.kind], + ); + const addDisabled = + createTrigger.isPending || + (newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false); + + useEffect(() => { + if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true); + }, [newTrigger.kind]); + + return ( +
+ {/* Add-trigger drawer header (§3.2) */} +
+

+ {routine.triggers.length === 0 + ? "No triggers yet" + : `${routine.triggers.length} trigger${routine.triggers.length === 1 ? "" : "s"}`} +

+ +
+ + {/* Add trigger form — expand-on-click drawer */} + {addOpen ? ( +
+

Add trigger

+
+
+ + +
+ {newTrigger.kind === "schedule" && ( +
+ + + setNewTrigger((current) => ({ ...current, cronExpression })) + } + onValidityChange={setNewScheduleEditorValid} + /> +
+ )} + {newTrigger.kind === "webhook" && ( + <> +
+ + +

+ {signingModeDescriptions[newTrigger.signingMode]} +

+
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && ( +
+ + + setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value })) + } + /> +
+ )} + + )} +
+
+ + +
+
+ ) : null} + + {/* Existing triggers */} + {routine.triggers.length === 0 ? ( + setAddOpen(true)} + /> + ) : ( +
+ {routine.triggers.map((trigger) => ( + updateTrigger.mutate({ id, patch })} + onRotate={(id) => rotateTrigger.mutate(id)} + onDelete={(id) => deleteTrigger.mutate(id)} + /> + ))} +
+ )} +
+ ); +} + +export function VariablesSection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, navigateToSection } = ctx; + const hasVariables = editDraft.variables.length > 0; + + return ( +
+
+ + Variables are auto-detected from {"{{placeholders}}"} in + the title & instructions. The variable name is read-only — rename by editing the + placeholder. + + +
+ + {hasVariables ? ( + setEditDraft((current) => ({ ...current, variables }))} + /> + ) : ( + navigateToSection("overview")} + /> + )} +
+ ); +} + +export function SecretsSection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx; + + // Project/company-scoped secrets that already see real usage, surfaced as + // quick-bind chips (§3.4). Ranked by reference count then recency. + const recentlyUsedSecrets = useMemo( + () => + [...availableSecrets] + .filter((secret) => secret.status === "active") + .sort((a, b) => { + const refDelta = (b.referenceCount ?? 0) - (a.referenceCount ?? 0); + if (refDelta !== 0) return refDelta; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }) + .slice(0, 8), + [availableSecrets], + ); + + return ( +
+
+ Routine secrets apply to every task this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* names are reserved. +
+ + {secretMessage ? ( +
+
+

{secretMessage.title}

+

+ Save this now. Paperclip will not show the secret value again. +

+
+
+ {secretMessage.entries.map((entry, index) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ ) : null} + + } + secrets={availableSecrets} + recentlyUsedSecrets={recentlyUsedSecrets} + onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })} + onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))} + /> +
+ ); +} + +export function DeliverySection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, routine } = ctx; + + // The activity gate only affects schedule ticks (webhook/manual/API fires are + // themselves activity and always run), so the control is only meaningful for + // routines that have a schedule trigger. Disable — rather than hide — it + // elsewhere so the capability stays discoverable. + const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule"); + const gateEnabled = editDraft.activityGatePolicy === "require_external_activity"; + + return ( +
+
+

+ Concurrency +

+ + setEditDraft((current) => ({ ...current, concurrencyPolicy })) + } + options={concurrencyPolicyOptions} + /> +
+
+

+ Catch-up +

+ + setEditDraft((current) => ({ ...current, catchUpPolicy })) + } + options={catchUpPolicyOptions} + /> +
+
+

+ Advanced run policy +

+ + setEditDraft((current) => ({ ...current, activityGatePolicy })) + } + options={activityGatePolicyOptions} + disabled={!hasScheduleTrigger} + /> + {!hasScheduleTrigger ? ( +

+ Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always + run. +

+ ) : gateEnabled ? ( +
+ + + setEditDraft((current) => ({ ...current, activityGateScope })) + } + options={activityGateScopeOptions} + /> +
+ ) : null} +
+ +
+ ); +} + +const dispositionToneClass: Record = { + queued: "text-emerald-600 dark:text-emerald-400", + coalesced: "text-amber-600 dark:text-amber-400", + skipped: "text-muted-foreground", +}; + +/** + * "Next 5 fires" preview (§3.5) — the strongest "what does this policy mean?" + * surface. Picks the soonest-firing schedule trigger, computes its next fires + * client-side, and annotates each with how the chosen concurrency policy would + * treat it. + */ +function NextFiresPreview({ + triggers, + concurrencyPolicy, +}: { + triggers: RoutineDetailType["triggers"]; + concurrencyPolicy: string; +}) { + const preview = useMemo(() => { + const schedule = triggers + .filter((trigger) => trigger.kind === "schedule" && trigger.enabled && trigger.cronExpression) + .map((trigger) => { + const fires = nextCronFires(trigger.cronExpression, 5, { + timeZone: trigger.timezone ?? "UTC", + }); + return { trigger, fires }; + }) + .filter((entry) => entry.fires.length > 0) + .sort((a, b) => a.fires[0]!.getTime() - b.fires[0]!.getTime())[0]; + if (!schedule) return null; + return { + timeZone: schedule.trigger.timezone ?? "UTC", + entries: previewFirePolicies(schedule.fires, concurrencyPolicy), + }; + }, [triggers, concurrencyPolicy]); + + return ( +
+

+ Next 5 fires +

+ {preview ? ( + <> +
+ {preview.entries.map((entry, index) => ( +
+ · + {formatFireTime(entry.at, preview.timeZone)} + + + {entry.label} + + {entry.note ? ( + ({entry.note}) + ) : null} +
+ ))} +
+

+ Preview assumes the previous run is still in flight when the next fires. Times shown in{" "} + {preview.timeZone}. +

+ + ) : ( +

+ No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy + treats upcoming fires. +

+ )} +
+ ); +} + +function formatFireTime(date: Date, timeZone: string): string { + try { + return new Intl.DateTimeFormat(undefined, { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .format(date) + .replace(",", ""); + } catch { + return date.toISOString(); + } +} diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index a80ff3d4ba..4321a3ec44 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -4,7 +4,6 @@ import { Braces, Clock3, Edit3, - KeyRound, Play, Plus, X, @@ -144,7 +143,6 @@ export function OverviewSection({ .sort((a, b) => a.getTime() - b.getTime())[0]; return upcoming ? upcoming.toLocaleString() : null; }, [routine.triggers]); - const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0; const lastRun = (routineRuns ?? [])[0] ?? null; const recentActivity = (activity ?? []).slice(0, 5); @@ -320,7 +318,7 @@ export function OverviewSection({ {/* Summary cards */} -
+
navigateToSection("triggers")} ariaLabel={`${activeTriggers} triggers. Open triggers.`} /> - navigateToSection("secrets")} - ariaLabel={`${boundSecrets} secrets bound. Open secrets.`} - /> ({ }, })); +vi.mock("../components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -279,6 +283,13 @@ describe("PromptsTab instruction editor", () => { await flushReact(); } + async function selectInstructionMode(mode: "Read" | "Edit" | "Raw") { + await act(async () => { + buttonByText(container, mode.toLowerCase()).click(); + }); + await flushReact(); + } + it("uses server markdown metadata for extensionless files and saves MarkdownEditor drafts", async () => { const summary = makeSummary("AGENTS", "AGENTS", { language: "markdown", @@ -289,6 +300,13 @@ describe("PromptsTab instruction editor", () => { { AGENTS: makeDetail(summary, "# Current") }, ); + await waitFor(() => { + expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toBe("# Current"); + }); + await selectInstructionMode("Raw"); + expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent?.trim()).toBe("# Current"); + await selectInstructionMode("Edit"); + const editor = await waitFor(() => { const candidate = container.querySelector('[data-testid="markdown-editor"]'); expect(candidate).not.toBeNull(); @@ -330,6 +348,7 @@ describe("PromptsTab instruction editor", () => { { "AGENTS.md": makeDetail(summary, "# Current") }, { onDirtyChange }, ); + await selectInstructionMode("Edit"); const editorProps = await waitFor(() => { const latest = markdownEditorRenderMock.mock.calls.at(-1)?.[0] as @@ -364,6 +383,7 @@ describe("PromptsTab instruction editor", () => { onCancelActionChange: (next) => { cancelAction = next; }, }, ); + await selectInstructionMode("Edit"); const editor = await waitFor(() => { const candidate = container.querySelector('[data-testid="markdown-editor"]'); @@ -401,6 +421,7 @@ describe("PromptsTab instruction editor", () => { { "settings.json": makeDetail(summary, "{\n \"ok\": true\n}") }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('textarea[placeholder="File contents"]')).not.toBeNull(); }); @@ -417,6 +438,7 @@ describe("PromptsTab instruction editor", () => { buttonByText(container, "Create").click(); }); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('[data-testid="markdown-editor"]')).not.toBeNull(); }); @@ -433,6 +455,7 @@ describe("PromptsTab instruction editor", () => { { "FALLBACK.md": makeDetail(summary, "# Fallback", { markdown: undefined }) }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector("[data-testid=\"markdown-editor\"]")).not.toBeNull(); expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -451,6 +474,7 @@ describe("PromptsTab instruction editor", () => { { "NOTES.md": makeDetail(summary, "raw instructions") }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull(); expect(container.querySelector('textarea[placeholder="File contents"]')?.value).toBe("raw instructions"); diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx new file mode 100644 index 0000000000..6bc2a0ce0e --- /dev/null +++ b/ui/src/pages/AgentDetail.production.tsx @@ -0,0 +1,4516 @@ +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 { budgetsApi } from "../api/budgets"; +import { heartbeatsApi } from "../api/heartbeats"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { ApiError } from "../api/client"; +import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; +import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; +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 { copyTextToClipboard } from "../lib/clipboard"; +import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab"; +import { AgentConfigForm } from "../components/AgentConfigForm"; +import { PageTabBar } from "../components/PageTabBar"; +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 { EntityRow } from "../components/EntityRow"; +import { StatusGlyph } from "../components/StatusGlyph"; +import { MembershipAction } from "../components/MembershipAction"; +import { StarToggle } from "../components/StarToggle"; +import { Identity } from "../components/Identity"; +import { AuditFeed } from "./audit/AuditFeed.production"; +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 { BudgetPolicyCard } from "../components/BudgetPolicyCard"; +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 { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; +import { cn } from "../lib/utils"; +import { describeRunRetryState } from "../lib/runRetryState"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + 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 { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; +import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { AgentToolsTab } from "./AgentToolsTab"; +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 BudgetPolicySummary, + type HeartbeatRun, + type HeartbeatRunEvent, + type AgentRuntimeState, + type LiveEvent, + type WorkspaceOperation, + isResponsibleUserDenialCode, + responsibleUserLabel, +} from "@paperclipai/shared"; +import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice"; +import { RunWorkspaceRecoverySurface } from "../components/RunWorkspaceRecoverySurface"; +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"; + +const runStatusIcons: Record = { + 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(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 ""; + + const keys = Object.keys(env); + if (keys.length === 0) return ""; + + return keys + .sort() + .map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`) + .join("\n"); +} + +const sourceLabels: Record = { + timer: "Timer", + assignment: "Assignment", + on_demand: "On-demand", + automation: "Automation", +}; + +const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; +type ScrollContainer = Window | HTMLElement; + +function isWindowContainer(container: ScrollContainer): container is Window { + return container === window; +} + +function isElementScrollContainer(element: HTMLElement): boolean { + const overflowY = window.getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; +} + +function findScrollContainer(anchor: HTMLElement | null): ScrollContainer { + let parent = anchor?.parentElement ?? null; + while (parent) { + if (isElementScrollContainer(parent)) return parent; + parent = parent.parentElement; + } + return window; +} + +function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + const viewportBottom = window.scrollY + window.innerHeight; + return { + scrollHeight: pageHeight, + distanceFromBottom: Math.max(0, pageHeight - viewportBottom), + }; + } + + const viewportBottom = container.scrollTop + container.clientHeight; + return { + scrollHeight: container.scrollHeight, + distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom), + }; +} + +function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + window.scrollTo({ top: pageHeight, behavior }); + return; + } + + container.scrollTo({ top: container.scrollHeight, behavior }); +} + +type AgentDetailView = "dashboard" | "instructions" | "configuration" | "secrets" | "skills" | "tools" | "runs" | "audit" | "budget"; + +export const AGENT_DETAIL_TABS: ReadonlyArray<{ value: AgentDetailView; label: string }> = [ + { 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" }, +]; + +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, + 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 function parseAgentDetailView(value: string | null): AgentDetailView { + if (value === "instructions" || value === "prompts") return "instructions"; + if (value === "configure" || value === "configuration") return "configuration"; + if (value === "secrets") return "secrets"; + if (value === "skills") return "skills"; + if (value === "tools") return "tools"; + if (value === "budget") return "budget"; + if (value === "audit") return "audit"; + if (value === "runs") return value; + return "dashboard"; +} + +function usageNumber(usage: Record | 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(left: Set, right: Set) { + 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 | null; + const result = (run.resultJson ?? null) as Record | 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: input + output, + provider, + model, + }; +} + +export type RunLogChunk = { + ts: string; + stream: "stdout" | "stderr" | "system"; + chunk: string; +}; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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, + 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; + 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 ( +
+
Invocation
+ {typeof payload.adapterType === "string" && ( +
Adapter: {payload.adapterType}
+ )} + {typeof payload.cwd === "string" && ( +
Working dir: {payload.cwd}
+ )} + {hasAdvancedDetails && ( + + + + Details + + + {commandLine && ( +
+ Command: + {commandLine} +
+ )} + {Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && ( +
+
Command notes
+
    + {payload.commandNotes + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((note, idx) => ( +
  • + {note} +
  • + ))} +
+
+ )} + {payload.prompt !== undefined && ( +
+
Prompt
+
+                  {typeof payload.prompt === "string"
+                    ? redactPathText(payload.prompt, censorUsernameInLogs)
+                    : JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
+                
+
+ )} + {payload.context !== undefined && ( +
+
Context
+
+                  {JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
+                
+
+ )} + {payload.env !== undefined && ( +
+
Environment
+
+                  {formatEnvForDisplay(payload.env, censorUsernameInLogs)}
+                
+
+ )} +
+
+ )} +
+ ); +} + +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 ( + + {status.replace("_", " ")} + + ); +} + +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 ( +
+ + {open && ( +
+ {isLoading &&
Loading log...
} + {error && ( +
+ {error instanceof Error ? error.message : "Failed to load workspace operation log"} +
+ )} + {!isLoading && !error && chunks.length === 0 && ( +
No persisted log lines.
+ )} + {chunks.length > 0 && ( +
+ {chunks.map((chunk, index) => ( +
+ + {new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })} + + + [{chunk.stream}] + + {redactPathText(chunk.chunk, censorUsernameInLogs)} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +function WorkspaceOperationsSection({ + operations, + censorUsernameInLogs, +}: { + operations: WorkspaceOperation[]; + censorUsernameInLogs: boolean; +}) { + if (operations.length === 0) return null; + + return ( +
+
+ Workspace ({operations.length}) +
+
+ {operations.map((operation) => { + const metadata = asRecord(operation.metadata); + return ( +
+
+
{workspaceOperationPhaseLabel(operation.phase)}
+ +
+ {relativeTime(operation.startedAt)} + {operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`} +
+
+ {operation.command && ( +
+ Command: + {operation.command} +
+ )} + {operation.cwd && ( +
+ Working dir: + {operation.cwd} +
+ )} + {(asNonEmptyString(metadata?.branchName) + || asNonEmptyString(metadata?.baseRef) + || asNonEmptyString(metadata?.worktreePath) + || asNonEmptyString(metadata?.repoRoot) + || asNonEmptyString(metadata?.cleanupAction)) && ( +
+ {asNonEmptyString(metadata?.branchName) && ( +
Branch: {metadata?.branchName as string}
+ )} + {asNonEmptyString(metadata?.baseRef) && ( +
Base ref: {metadata?.baseRef as string}
+ )} + {asNonEmptyString(metadata?.worktreePath) && ( +
Worktree: {metadata?.worktreePath as string}
+ )} + {asNonEmptyString(metadata?.repoRoot) && ( +
Repo root: {metadata?.repoRoot as string}
+ )} + {asNonEmptyString(metadata?.cleanupAction) && ( +
Cleanup: {metadata?.cleanupAction as string}
+ )} +
+ )} + {typeof metadata?.created === "boolean" && ( +
+ {metadata.created ? "Created by this run" : "Reused existing workspace"} +
+ )} + {operation.stderrExcerpt && operation.stderrExcerpt.trim() && ( +
+
stderr excerpt
+
+                    {redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
+                  
+
+ )} + {operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && ( +
+
stdout excerpt
+
+                    {redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
+                  
+
+ )} + {operation.logRef && ( + + )} +
+ ); + })} +
+
+ ); +} + +export function AgentDetail() { + const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{ + companyPrefix?: string; + agentId: string; + tab?: string; + runId?: string; + }>(); + const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { closePanel } = usePanel(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [actionError, setActionError] = useState(null); + const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set()); + const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null); + const needsDashboardData = activeView === "dashboard"; + const needsRunData = activeView === "runs" || Boolean(urlRunId); + const shouldLoadHeartbeats = needsDashboardData || needsRunData; + const [configDirty, setConfigDirty] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const saveConfigActionRef = useRef<(() => void) | null>(null); + const cancelConfigActionRef = useRef<(() => void) | null>(null); + const { isMobile } = useSidebar(); + const routeAgentRef = agentId ?? ""; + const routeCompanyId = useMemo(() => { + if (!companyPrefix) return null; + const requestedPrefix = companyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; + }, [companies, companyPrefix]); + const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; + const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId)); + const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); + const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); + const prepareAgentNavigation = useCallback(() => { + return confirmAgentConfigNavigation(configDirty); + }, [configDirty]); + + const { data: agent, isLoading, error } = useQuery({ + queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], + queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), + enabled: canFetchAgent, + }); + const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; + const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; + const agentLookupRef = agent?.id ?? routeAgentRef; + const resolvedAgentId = agent?.id ?? null; + const 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) && needsDashboardData, + }); + + const { data: heartbeats } = useQuery({ + queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), + queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), + enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats, + }); + + const { data: allIssues } = useQuery({ + queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }), + enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData, + }); + + const { data: allAgents } = useQuery({ + queryKey: queryKeys.agents.list(resolvedCompanyId!), + queryFn: () => agentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId && needsDashboardData, + }); + + const { data: budgetOverview } = useQuery({ + queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"), + queryFn: () => budgetsApi.overview(resolvedCompanyId!), + enabled: !!resolvedCompanyId, + refetchInterval: 30_000, + staleTime: 5_000, + }); + + const assignedIssues = (allIssues ?? []) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); + const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); + const agentBudgetSummary = useMemo(() => { + const matched = budgetOverview?.policies.find( + (policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef), + ); + if (matched) return matched; + const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0; + const spentMonthlyCents = agent?.spentMonthlyCents ?? 0; + return { + policyId: "", + companyId: resolvedCompanyId ?? "", + scopeType: "agent", + scopeId: agent?.id ?? routeAgentRef, + scopeName: agent?.name ?? "Agent", + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: budgetMonthlyCents, + observedAmount: spentMonthlyCents, + remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents), + utilizationPercent: + budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0, + warnPercent: 80, + hardStopEnabled: true, + notifyEnabled: true, + isActive: budgetMonthlyCents > 0, + status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok", + paused: agent?.status === "paused", + pauseReason: agent?.pauseReason ?? null, + windowStart: new Date(), + windowEnd: new Date(), + } satisfies BudgetPolicySummary; + }, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]); + const mobileLiveRun = useMemo( + () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, + [heartbeats], + ); + + useEffect(() => { + if (!agent) return; + if (urlRunId) { + if (routeAgentRef !== canonicalAgentRef) { + navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); + } + return; + } + const canonicalTab = + activeView === "instructions" + ? "instructions" + : activeView === "configuration" + ? "configuration" + : activeView === "secrets" + ? "secrets" + : activeView === "skills" + ? "skills" + : activeView === "tools" + ? "tools" + : activeView === "runs" + ? "runs" + : activeView === "audit" + ? "audit" + : activeView === "budget" + ? "budget" + : "dashboard"; + if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { + navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); + return; + } + }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]); + + useEffect(() => { + if (!agent?.companyId || agent.companyId === selectedCompanyId) return; + setSelectedCompanyId(agent.companyId, { source: "route_sync" }); + }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); + + // 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 budgetMutation = useMutation({ + mutationFn: (amount: number) => + budgetsApi.upsertPolicy(resolvedCompanyId!, { + scopeType: "agent", + scopeId: agent?.id ?? routeAgentRef, + amount, + windowKind: "calendar_month_utc", + }), + onSuccess: () => { + if (!resolvedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) }); + }, + }); + + const updateIcon = useMutation({ + mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + } + }, + }); + + const 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 === "dashboard" && !urlRunId) { + crumbs.push({ label: agentName }); + } else { + crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` }); + if (urlRunId) { + crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); + crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); + } else if (activeView === "instructions") { + crumbs.push({ label: "Instructions" }); + } else if (activeView === "configuration") { + crumbs.push({ label: "Configuration" }); + } else if (activeView === "secrets") { + crumbs.push({ label: "Secrets" }); + // } else if (activeView === "skills") { // TODO: bring back later + // crumbs.push({ label: "Skills" }); + } else if (activeView === "tools") { + crumbs.push({ label: "Tools" }); + } else if (activeView === "runs") { + crumbs.push({ label: "Runs" }); + } else if (activeView === "budget") { + crumbs.push({ label: "Budget" }); + } else { + crumbs.push({ label: "Dashboard" }); + } + } + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]); + + useEffect(() => { + closePanel(); + return () => closePanel(); + }, [closePanel]); + + 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 ; + if (error) return

{error.message}

; + if (!agent) return null; + if (!urlRunId && !urlTab) { + return ; + } + 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 === "configuration" || activeView === "instructions" || activeView === "secrets" + ) && (configDirty || configSaving); + 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; + + function handleAgentTabChange(value: string) { + if (value === activeView || !prepareAgentNavigation()) return; + navigate(`/agents/${canonicalAgentRef}/${value}`); + } + + return ( +
+ {showLeftAgentNotice ? ( +
+

+ You left this agent. It no longer appears in your sidebar. +

+ 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", + })} + /> + +
+ ) : null} + {pausedEscalationWarning ? ( +
+ +
+

Escalation path is paused

+

{pausedEscalationWarning}

+
+
+ ) : null} + {hasInvalidOrgChain ? ( +
+ +
+

Invalid reporting chain

+

+ {agent.name} cannot accept tasks or start runs until its reporting chain is repaired. +

+

+ {formatOrgChainHealthPath(agent)} +

+ {agent.orgChainHealth?.repairGuidance ? ( +

{agent.orgChainHealth.repairGuidance}

+ ) : ( +

+ Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree. +

+ )} +
+
+ ) : null} + {/* Header */} +
+
+ updateIcon.mutate(icon)} + > + + +
+
+

{agent.name}

+
+

+ {roleLabels[agent.role] ?? agent.role} + {agent.title ? ` - ${agent.title}` : ""} +

+
+
+
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> + 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 && ( + + + + + + Live + + )} + +
+
+ + {builtInState && ( + resetBuiltIn.mutate()} + disabled={resetBuiltIn.isPending} + > + {resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"} + + } + > + Ships with Paperclip and powers {builtInFeatureLabel}. Configure it like + any agent — model, instructions, budget. It can be paused but not deleted; pausing it + pauses {builtInFeatureLabel}. + + )} + + {builtInState?.definition.bundle && ( + 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 && ( + { + setShowBuiltInConfigure(false); + invalidateBuiltIn(); + }} + /> + )} + + {!urlRunId && ( + + + + )} + + {actionError &&

{actionError}

} + {isPendingApproval && ( +
+ This agent is pending board approval and cannot be invoked yet. + +
+ )} + + {/* Floating Save/Cancel (desktop) */} + {!isMobile && showConfigActionBar && ( +
+
+ + +
+
+ )} + + {/* Mobile bottom Save/Cancel bar */} + {isMobile && showConfigActionBar && ( +
+
+ + +
+
+ )} + + {/* View content */} + {activeView === "dashboard" && ( + + )} + + {activeView === "instructions" && ( + + )} + + {activeView === "configuration" && ( + + )} + + {activeView === "secrets" && ( +
+ +
+ )} + + {activeView === "skills" && ( + + )} + + {activeView === "tools" && resolvedCompanyId && ( + + )} + + {activeView === "runs" && ( + + )} + + {activeView === "audit" && resolvedCompanyId ? ( + + ) : null} + + {activeView === "budget" && resolvedCompanyId ? ( +
+ budgetMutation.mutate(amount)} + variant="plain" + /> +
+ ) : null} +
+ ); +} + +/* ---- Helper components ---- */ + +function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +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, +): string | undefined { + const ctx = run.contextSnapshot as Record | 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, + agentId: string, + issuesById: Map, +): { 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; +}) { + 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).summary ?? (run.resultJson as Record).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 ( +
+
+ +

+ {isLive && ( + + + + + )} + {isLive ? "Live Run" : "Latest Run"} + + · {run.id.slice(0, 8)} + +

+ +
+ + +
+ + + {task ? ( + <> + + + {task.identifier ?? task.id.slice(0, 8)} + + {task.title} + + ) : ( + <> + {run.id.slice(0, 8)} + + {sourceLabels[run.invocationSource] ?? run.invocationSource} + + + )} + {relativeTime(run.createdAt)} +
+ + {summary && ( +
+ {summary} +
+ )} + +
+ ); +} + +/* ---- Agent Overview (main single-page view) ---- */ + +function AgentOverview({ + agent, + runs, + assignedIssues, + runtimeState, + agentId, + agentRouteId, +}: { + agent: AgentDetailRecord; + runs: HeartbeatRun[]; + assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; + runtimeState?: AgentRuntimeState; + agentId: string; + agentRouteId: string; +}) { + const issuesById = useMemo(() => { + const map = new Map(); + for (const issue of assignedIssues) map.set(issue.id, issue); + return map; + }, [assignedIssues]); + + return ( +
+ {/* Latest Run */} + + + {/* Charts */} +
+ + + + {/* PAP-411: "Tasks by Priority" chart hidden behind SHOW_TASK_PRIORITY_UI. */} + {SHOW_TASK_PRIORITY_UI && ( + + + + )} + + + + + + +
+ + {/* Recent Issues */} +
+
+

Recent Tasks

+ + See All → + +
+ {assignedIssues.length === 0 ? ( +

No recent tasks.

+ ) : ( +
+ {assignedIssues.slice(0, 10).map((issue) => ( + } + /> + ))} + {assignedIssues.length > 10 && ( +
+ +{assignedIssues.length - 10} more tasks +
+ )} +
+ )} +
+ + {/* Costs */} +
+

Costs

+ +
+
+ ); +} + +/* ---- Costs Section (inline) ---- */ + +function CostsSection({ + runtimeState, + runs, +}: { + runtimeState?: AgentRuntimeState; + runs: HeartbeatRun[]; +}) { + const runsWithCost = runs + .filter((r) => { + const metrics = runMetrics(r); + return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return ( +
+ {runtimeState && ( +
+
+
+ Input tokens + {formatTokens(runtimeState.totalInputTokens)} +
+
+ Output tokens + {formatTokens(runtimeState.totalOutputTokens)} +
+
+ Cached tokens + {formatTokens(runtimeState.totalCachedInputTokens)} +
+
+ Total cost + {formatCents(runtimeState.totalCostCents)} +
+
+
+ )} + {runsWithCost.length > 0 && ( +
+ + + + + + + + + + + + {runsWithCost.slice(0, 10).map((run) => { + const metrics = runMetrics(run); + return ( + + + + + + + + ); + })} + +
DateRunInputOutputCost
{formatDate(run.createdAt)}{run.id.slice(0, 8)}{formatTokens(metrics.input)}{formatTokens(metrics.output)} + {metrics.cost > 0 + ? `$${metrics.cost.toFixed(4)}` + : "-" + } +
+
+ )} +
+ ); +} + +/* ---- 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; +} + +function AgentConfigurePage({ + agent, + agentId, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, + updatePermissions, +}: { + agent: AgentDetailRecord; + agentId: string; + 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 }; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { tab: urlTab } = useParams<{ tab?: string }>(); + const [revisionsOpen, setRevisionsOpen] = useState(false); + + const { data: configRevisions } = useQuery({ + queryKey: queryKeys.agents.configRevisions(agent.id), + queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId), + }); + + const rollbackConfig = useMutation({ + mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId), + onSuccess: (updated) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? "configuration")) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + } + }, + }); + + return ( +
+ +
+

API Keys

+ +
+ + {/* Configuration Revisions — collapsible at the bottom */} +
+ + {revisionsOpen && ( +
+ {(configRevisions ?? []).length === 0 ? ( +

No configuration revisions yet.

+ ) : ( +
+ {(configRevisions ?? []).slice(0, 10).map((revision) => ( +
+
+
+ {revision.id.slice(0, 8)} + · + {formatDate(revision.createdAt)} + · + {revision.source} +
+ +
+

+ Changed:{" "} + {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} +

+
+ ))} +
+ )} +
+ )} +
+
+ ); +} + +/* ---- Configuration Tab ---- */ + +function ConfigurationTab({ + agent, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, + updatePermissions, + hidePromptTemplate, + hideInstructionsFile, + content = "configuration", +}: { + 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?: "configuration" | "secrets"; +}) { + 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 { data: adapterModels } = useQuery({ + queryKey: + companyId + ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + : ["agents", "none", "adapter-models", agent.adapterType], + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + enabled: Boolean(companyId) && content === "configuration", + }); + + 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 === "configuration", + }); + + 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 === "configuration", + }); + + const updateAgent = useMutation({ + mutationFn: (data: Record) => 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 = updateAgent.isPending || awaitingRefreshAfterSave; + + useEffect(() => { + onSavingChange(isConfigSaving); + }, [onSavingChange, isConfigSaving]); + + 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 company permission grant." + : taskAssignSource === "simple_default" + ? "Enabled by simple company-wide task assignment defaults." + : "Disabled unless explicitly granted."; + + return ( +
+ updateAgent.mutateAsync(patch)} + isSaving={isConfigSaving} + adapterModels={adapterModels} + onDirtyChange={onDirtyChange} + onSaveActionChange={onSaveActionChange} + onCancelActionChange={onCancelActionChange} + hideInlineSave + hidePromptTemplate={hidePromptTemplate} + hideInstructionsFile={hideInstructionsFile} + content={content} + sectionLayout="cards" + /> + {content === "configuration" ? ( +

+ Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session. +

+ ) : null} + + {content === "configuration" ? ({ + 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 === "configuration" ?
+

Permissions

+
+
+
+
Can create new agents
+

+ Lets this agent create or hire agents. This also grants task assignment authority. +

+
+ + updatePermissions.mutate({ + canCreateAgents: !canCreateAgents, + canCreateSkills, + canAssignTasks: !canCreateAgents ? true : canAssignTasks, + }) + } + disabled={updatePermissions.isPending} + /> +
+
+
+
Can create/import skills
+

+ Lets this agent install, import, create, and scan company skills without creating agents. +

+
+ + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills: !canCreateSkills, + canAssignTasks, + }) + } + disabled={updatePermissions.isPending} + /> +
+
+
+
Can assign tasks
+

+ {taskAssignHint} +

+
+ + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills, + canAssignTasks: !canAssignTasks, + }) + } + disabled={updatePermissions.isPending || taskAssignLocked} + /> +
+
+
: null} +
+ ); +} + +/* ---- Prompts Tab ---- */ + +export function PromptsTab({ + agent, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, +}: { + agent: Agent; + companyId?: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; +}) { + const queryClient = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const { isMobile } = useSidebar(); + const [selectedFile, setSelectedFile] = useState("AGENTS.md"); + const [showFilePanel, setShowFilePanel] = useState(false); + const [draft, setDraft] = useState(null); + 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([]); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [filePanelWidth, setFilePanelWidth] = useState(260); + const [instructionPaneWidth, setInstructionPaneWidth] = useState(null); + const containerRef = useRef(null); + const [awaitingRefresh, setAwaitingRefresh] = useState(false); + const lastFileVersionRef = useRef(null); + const externalBundleRef = useRef<{ + rootPath: string; + entryFile: string; + selectedFile: string; + } | null>(null); + + useEffect(() => { + setSelectedFile("AGENTS.md"); + setShowFilePanel(false); + setDraft(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), + }); + + 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 } = useQuery({ + queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile), + queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId), + enabled: Boolean(companyId && isLocal && selectedFileExists), + }); + + const updateBundle = useMutation({ + mutationFn: (data: { + mode?: "managed" | "external"; + rootPath?: string | null; + entryFile?: string; + clearLegacyPromptTemplate?: boolean; + }) => agentsApi.updateInstructionsBundle(agent.id, data, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const saveFile = useMutation({ + mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) => + agentsApi.saveInstructionsFile(agent.id, data, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: (_, variables) => { + 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), + onMutate: () => setAwaitingRefresh(true), + onSuccess: (_, relativePath) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const uploadMarkdownImage = useMutation({ + mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to upload images"); + return assetsApi.uploadImage(selectedCompanyId, file, namespace); + }, + }); + + useEffect(() => { + if (!bundle) return; + if (!bundleMatchesDraft) { + if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile); + return; + } + const availablePaths = bundle.files.map((file) => file.path); + if (availablePaths.length === 0) { + if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile); + return; + } + if (!availablePaths.includes(selectedFile) && selectedFile !== currentEntryFile && !pendingFiles.includes(selectedFile)) { + setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!); + } + }, [bundle, bundleMatchesDraft, currentEntryFile, pendingFiles, selectedFile]); + + useEffect(() => { + const nextExpanded = new Set(); + 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) { + 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; + const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh; + + useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); + useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); + + useEffect(() => { + onSaveActionChange(isDirty ? () => { + const save = async () => { + const shouldClearLegacy = + Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive); + if (bundleDirty && bundleDraft) { + await updateBundle.mutateAsync({ + mode: bundleDraft.mode, + rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null, + entryFile: bundleDraft.entryFile, + }); + } + if (fileDirty) { + await saveFile.mutateAsync({ + path: selectedOrEntryFile, + content: displayValue, + clearLegacyPromptTemplate: shouldClearLegacy, + }); + } + }; + void save().catch(() => undefined); + } : null); + }, [ + bundle, + bundleDirty, + bundleDraft, + displayValue, + fileDirty, + isDirty, + onSaveActionChange, + saveFile, + selectedOrEntryFile, + updateBundle, + ]); + + useEffect(() => { + onCancelActionChange(isDirty ? () => { + setDraft(null); + if (bundle) { + setBundleDraft({ + mode: persistedMode, + rootPath: persistedRootPath, + entryFile: bundle.entryFile, + }); + } + } : null); + }, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]); + + const handleSeparatorDrag = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = filePanelWidth; + const onMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const next = Math.max(180, Math.min(500, startWidth + delta)); + setFilePanelWidth(next); + }; + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [filePanelWidth]); + + const instructionsSideBySide = + !isMobile && instructionPaneWidth !== null && instructionPaneWidth >= filePanelWidth + 520; + + if (!isLocal) { + return ( +
+

+ Instructions bundles are only available for local adapters. +

+
+ ); + } + + if (bundleLoading && !bundle) { + return ; + } + + return ( +
+ {(bundle?.warnings ?? []).length > 0 && ( +
+ {(bundle?.warnings ?? []).map((warning) => ( +
+ {warning} +
+ ))} +
+ )} +

+ Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session. +

+ + + + + Advanced + + + +
+ + + +
+
+
+
+ +
+
+
+

Files

+
+ {!showNewFileInput && ( + + )} + {isMobile && ( + + )} +
+
+ {showNewFileInput && ( +
+ setNewFilePath(event.target.value)} + placeholder="TOOLS.md" + className="font-mono text-sm" + autoFocus + onKeyDown={(event) => { + if (event.key === "Escape") { + setShowNewFileInput(false); + setNewFilePath(""); + } + }} + /> +
+ + +
+
+ )} + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(dirPath)) next.delete(dirPath); + else next.add(dirPath); + return next; + })} + onSelectFile={(filePath) => { + setSelectedFile(filePath); + if (!fileOptions.includes(filePath)) setDraft(""); + 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 ( + + + + virtual file + + + + Legacy inline prompt — this deprecated virtual file preserves the old promptTemplate content + + + ); + } + return ( + + {file.isEntryFile ? "entry" : `${file.size}b`} + + ); + }} + /> +
+ + {/* Draggable separator */} + {instructionsSideBySide && ( +
+ )} + +
+
+
+ {isMobile && ( + + )} +
+

{selectedOrEntryFile}

+

+ {selectedFileExists + ? selectedFileSummary?.deprecated + ? "Deprecated virtual file" + : `${selectedFileDetail?.language ?? "text"} file` + : "New file in this bundle"} +

+
+
+
+ {!fileLoading && ( + + + + )} + {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && ( + + )} +
+
+ + {selectedFileExists && fileLoading && !selectedFileDetail ? ( + + ) : useMarkdownEditor ? ( + 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; + }} + /> + ) : ( +