feat(ui): refine streamlined workspace surfaces (#12747)

This commit is contained in:
scotttong 2026-09-02 23:55:55 -07:00 committed by GitHub
parent 597fd63b61
commit b1f4910ee5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 20364 additions and 1163 deletions

View File

@ -69,6 +69,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({
},
}));
vi.mock("./pages/audit/AuditHub", () => ({
AuditHub: ({ section }: { section: string }) => {
const location = useLocation();
return <div>{`AUDIT_${section.toUpperCase()}@${location.pathname}${location.search}`}</div>;
},
}));
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");

View File

@ -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() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<CompanyActivity />} />
<Route path="activity/runs" element={<AuditHub section="runs" />} />
<Route path="activity/costs" element={<AuditHub section="costs" />} />
<Route path="activity/budgets" element={<AuditHub section="budgets" />} />
<Route path="activity/timeline" element={<AuditHub section="timeline" />} />
{/* `/audit` merged into the single Activity page (PAP-16302). Existing deep
links keep working, preset to the agent-actions scope. */}
<Route path="audit" element={<Navigate to="/activity?mode=agents" replace />} />

View File

@ -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<typeof createRoot> | 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(
<CollectionToolbar
ariaLabel="Task list controls"
context={<span>Mine</span>}
search={<input aria-label="Search tasks" />}
controls={<button type="button">Filter</button>}
actions={<button type="button">New task</button>}
feedback={<span>Status: active</span>}
/>,
);
});
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(<CollectionToolbar search={<span>Search</span>} />));
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();
});
});

View File

@ -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 (
<div
data-slot="collection-toolbar"
className={cn("flex flex-col gap-2", className)}
role="toolbar"
aria-label={ariaLabel}
>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
{context ? (
<div data-slot="collection-toolbar-context" className="min-w-0 shrink-0">
{context}
</div>
) : null}
{search ? (
<div data-slot="collection-toolbar-search" className="min-w-0 flex-1">
{search}
</div>
) : null}
{(controls || actions) ? (
<div className="flex min-w-0 flex-wrap items-center gap-1 sm:ml-auto sm:flex-nowrap">
{controls ? (
<div data-slot="collection-toolbar-controls" className="flex min-w-0 flex-wrap items-center gap-1">
{controls}
</div>
) : null}
{actions ? (
<div data-slot="collection-toolbar-actions" className="flex shrink-0 items-center gap-1">
{actions}
</div>
) : null}
</div>
) : null}
</div>
{feedback ? (
<div data-slot="collection-toolbar-feedback" className="min-w-0">
{feedback}
</div>
) : null}
</div>
);
}

View File

@ -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(
<IssueRow
issue={createIssue({ identifier: "PAP-42", title: "Canonical task" })}
presentation="task"
metadata={<span>Live</span>}
actions={<button type="button">More</button>}
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(
<IssueRow
issue={createIssue()}
presentation="task"
onArchive={() => undefined}
/>,
);
});
const archiveButton = container.querySelector<HTMLButtonElement>('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(<IssueRow issue={createIssue()} onArchive={() => undefined} />);
});
const archiveButton = container.querySelector<HTMLButtonElement>('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(
<IssueRow
issue={createIssue()}
presentation="task"
unreadState="visible"
onMarkRead={onMarkRead}
/>,
);
});
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<HTMLButtonElement>('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(
<>
<IssueRow issue={createIssue({ id: "read" })} presentation="task" unreadState="hidden" />
<IssueRow issue={createIssue({ id: "plain" })} presentation="task" />
</>,
);
});
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(
<IssueRow
issue={createIssue()}
presentation="task"
treeGuides={2}
chevronInGuide
leadingControl={<button type="button">Expand</button>}
/>,
);
});
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(<IssueRow issue={createIssue()} presentation="task" showDivider />);
});
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(
<IssueRow
issue={recoveryIssue(at(-5 * 60_000))}
presentation={presentation}
/>,
);
});
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), {

View File

@ -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 (
<button
@ -89,7 +103,10 @@ export function InboxArchiveButton({
onArchive();
}}
disabled={disabled}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30",
compact ? "h-5 py-0" : "py-1",
)}
aria-label="Archive"
>
<Archive className="h-3.5 w-3.5" />
@ -102,6 +119,12 @@ export function IssueRow({
issue,
issueLinkState,
selected = false,
presentation = "legacy",
leadingControl,
statusSlot,
metadata,
actions,
showIdentifier = true,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
@ -127,10 +150,9 @@ export function IssueRow({
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// A row participates in the unread system whenever `unreadState` is supplied
// (inbox rows). It then reserves a fixed leading dot slot on all rows — read
// and unread alike — so the mark-read dot sits in the far-left gutter without
// shifting content, matching the sibling non-issue inbox rows.
// A row participates in the unread system whenever `unreadState` is supplied.
// Canonical rows overlay this affordance in their shared gutter, while legacy
// inbox rows retain their reserved slot until that presentation is migrated.
const showUnreadSlot = unreadState != null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const unreadDotButton = (
@ -202,6 +224,134 @@ export function IssueRow({
</Badge>
) : null;
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
return (
<div
onMouseEnter={onMouseEnter}
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pl-4 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
className,
)}
>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className="absolute inset-0 rounded-lg no-underline text-inherit focus-visible:z-10 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring"
>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
{showUnreadSlot ? (
<span
data-testid="issue-row-unread-slot"
className="absolute left-0 top-3 inline-flex h-4 w-4 items-center justify-center sm:top-1/2 sm:-translate-y-1/2"
>
{showUnreadDot ? unreadDotButton : null}
</span>
) : null}
<span data-slot="task-row-leading" className="flex shrink-0 items-center gap-1 pt-px sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
<span
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
aria-hidden="true"
className="relative hidden w-4 shrink-0 self-stretch sm:block"
>
<span
data-slot="task-row-tree-connector"
className="absolute -inset-y-3 left-7 w-px bg-background"
>
{gapForChevron ? (
<span className="absolute inset-0 flex flex-col">
<span className="flex-1 bg-border" />
<span className="h-3.5 shrink-0" />
<span className="flex-1 bg-border" />
</span>
) : (
<span className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{leadingControl}
{statusSlot ?? (
<StatusIcon
status={issue.status}
blockerAttention={issue.blockerAttention}
size="md"
className={selectedStatusClass}
/>
)}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-start gap-1.5 sm:items-center">
<span
data-slot="task-row-title"
className={cn(
"min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none",
isUnread && "font-semibold",
titleClassName,
)}
>
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
) : null}
{mobileMeta ? (
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
) : null}
</span>
<span
data-slot="task-row-trailing"
className="ml-auto hidden min-w-0 shrink-0 items-center gap-2 sm:flex"
>
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" className="min-w-0">{metadata}</span> : null}
{desktopTrailing}
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
{showIdentifier ? (
<span data-slot="task-row-identifier" className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{identifier}
</span>
) : null}
{trailingMeta ? (
<span data-slot="task-row-timestamp" className="w-24 shrink-0 truncate text-right text-xs text-muted-foreground">
{trailingMeta}
</span>
) : null}
</span>
</div>
);
}
return (
<div
onMouseEnter={onMouseEnter}
@ -243,11 +393,16 @@ export function IssueRow({
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
{issue.title}{titleSuffix}
<span data-slot="task-row-title-cluster" className="flex min-w-0 items-start gap-1.5 sm:order-2 sm:flex-1 sm:items-center">
<span
data-slot="task-row-title"
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", titleClassName)}
>
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1 sm:order-3 sm:ml-(--sz-calc-13)">
@ -317,7 +472,6 @@ export function IssueRow({
{identifier}
</span>
{parkedBlockerIndicator}
{recoveryIndicator}
</>
)}
{mobileMeta ? (
@ -377,7 +531,7 @@ function renderRecoveryChip(
role="status"
aria-label={detail ? `${label}${detail}` : label}
className={cn(
"ml-1.5 gap-0.5 text-(length:--text-nano)",
"shrink-0 gap-0.5 text-(length:--text-nano)",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}

View File

@ -18,24 +18,16 @@ describe("KeyboardShortcutsCheatsheet", () => {
document.body.innerHTML = "";
});
it("lists the re-pointed Cmd/Ctrl+B sidebar collapse shortcut as a chord", () => {
it("does not advertise the retired sidebar collapse shortcut", () => {
const root = createRoot(container);
flushSync(() => {
root.render(<KeyboardShortcutsCheatsheetContent />);
});
// The collapse/expand row exists with its label.
const row = [...container.querySelectorAll("span")].find(
(node) => node.textContent?.trim() === "Collapse or expand sidebar",
)?.parentElement;
expect(row).toBeTruthy();
// Rendered as a "+" chord (B + a Cmd/Ctrl cap), not a "then" sequence.
const caps = [...(row?.querySelectorAll("kbd") ?? [])].map((kbd) => kbd.textContent);
expect(caps).toContain("B");
expect(caps.some((cap) => cap === "⌘" || cap === "Ctrl")).toBe(true);
expect(row?.textContent).toContain("+");
expect(row?.textContent).not.toContain("then");
expect(row).toBeUndefined();
flushSync(() => {
root.unmount();

View File

@ -8,17 +8,6 @@ interface ShortcutEntry {
combo?: boolean;
}
// Platform-appropriate label for the Cmd/Ctrl modifier so the cheatsheet shows
// the same key the user actually presses (re-pointed in the collapsible sidebar
// work — Cmd/Ctrl+B toggles the rail).
function getPlatformLabel() {
if (typeof navigator === "undefined") return "";
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
return nav.userAgentData?.platform || navigator.userAgent || "";
}
const META_KEY = /Mac|iPhone|iPad|iPod/.test(getPlatformLabel()) ? "⌘" : "Ctrl";
interface ShortcutSection {
title: string;
shortcuts: ShortcutEntry[];
@ -66,7 +55,6 @@ const sections: ShortcutSection[] = [
{ keys: ["/"], label: "Search current page or quick search" },
{ keys: ["c"], label: "New task" },
{ keys: ["["], label: "Toggle sidebar" },
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
{ keys: ["]"], label: "Toggle panel" },
{ keys: ["?"], label: "Show keyboard shortcuts" },
],

View File

@ -0,0 +1,203 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { RoutineDetail, RoutineRunSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineDetailContext, type RoutineDetailContextValue } from "./routine-sections/context";
import {
RoutineOverview,
routineRunIssue,
summarizeRoutineSchedule,
} from "./RoutineOverview";
const issueRowRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/IssueRow", () => ({
IssueRow: (props: { issue: { title: string }; presentation?: string }) => {
issueRowRender(props);
return <div data-slot="task-row">{props.issue.title}</div>;
},
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown">{children}</div>,
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>{children}</a>
),
}));
const triggeredAt = new Date("2026-08-31T16:00:00.000Z");
const run: RoutineRunSummary = {
id: "run-1",
companyId: "company-1",
routineId: "routine-1",
triggerId: "trigger-1",
source: "schedule",
status: "succeeded",
triggeredAt,
idempotencyKey: null,
triggerPayload: null,
dispatchFingerprint: null,
linkedIssueId: "issue-1",
coalescedIntoRunId: null,
failureReason: null,
completedAt: triggeredAt,
createdAt: triggeredAt,
updatedAt: triggeredAt,
trigger: { id: "trigger-1", kind: "schedule", label: "weekday" },
linkedIssue: {
id: "issue-1",
identifier: "PAP-42",
title: "Prepare the release digest",
status: "done",
priority: "high",
updatedAt: triggeredAt,
},
};
const routine = {
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
folderId: null,
goalId: null,
parentIssueId: null,
title: "Weekly release review",
description: "Summarize **release readiness** for the operator.",
assigneeAgentId: "agent-1",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
variables: [],
env: { PRIVATE_TOKEN: { type: "secret_ref", secretId: "secret-1" } },
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: triggeredAt,
lastEnqueuedAt: triggeredAt,
createdAt: triggeredAt,
updatedAt: triggeredAt,
project: null,
assignee: { id: "agent-1", name: "Release Manager", role: "manager", title: null, urlKey: "release-manager" },
parentIssue: null,
triggers: [{
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "weekday",
enabled: true,
cronExpression: "0 9 * * 1-5",
timezone: "America/Los_Angeles",
nextRunAt: new Date("2026-09-01T16:00:00.000Z"),
lastFiredAt: triggeredAt,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: "succeeded",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: triggeredAt,
updatedAt: triggeredAt,
}],
recentRuns: [run],
activeIssue: null,
} as RoutineDetail;
function contextFixture(): RoutineDetailContextValue {
return {
routine,
routineId: routine.id,
companyId: routine.companyId,
routineRuns: [run],
currentAssignee: {
id: "agent-1",
name: "Release Manager",
urlKey: "release-manager",
},
hasLiveRun: false,
} as RoutineDetailContextValue;
}
describe("RoutineOverview", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
issueRowRender.mockClear();
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-08-31T17:00:00.000Z").getTime());
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("summarizes enabled schedules and their next run", () => {
expect(summarizeRoutineSchedule(routine.triggers)).toMatchObject({
label: "1 active schedule",
detail: "0 9 * * 1-5 · America/Los_Angeles",
nextRunAt: new Date("2026-09-01T16:00:00.000Z"),
});
expect(summarizeRoutineSchedule([{ ...routine.triggers[0]!, enabled: false }])).toEqual({
label: "No active schedule",
detail: "Manual runs only",
nextRunAt: null,
});
});
it("adapts compact run tasks to the canonical task presentation", () => {
const issue = routineRunIssue(run.linkedIssue!, run, "company-1", "project-1");
expect(issue).toMatchObject({
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
status: "done",
priority: "high",
originKind: "routine_execution",
originId: "routine-1",
originRunId: "run-1",
});
});
it("shows operational facts, readable description, agent, and recent task rows without secrets", () => {
flushSync(() => root.render(
<RoutineDetailContext.Provider value={contextFixture()}>
<RoutineOverview />
</RoutineDetailContext.Provider>,
));
expect(container.textContent).toContain("1 active schedule");
expect(container.textContent).toContain("Next run");
expect(container.textContent).toContain("Release Manager");
expect(container.textContent).toContain("Summarize **release readiness** for the operator.");
expect(container.textContent).toContain("Prepare the release digest");
expect(container.textContent).not.toContain("PRIVATE_TOKEN");
expect(container.textContent).not.toContain("secret-1");
expect(issueRowRender).toHaveBeenCalledWith(expect.objectContaining({ presentation: "task" }));
expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]'))
.not.toBeNull();
});
});

View File

@ -0,0 +1,249 @@
import type {
Issue,
IssuePriority,
IssueStatus,
RoutineRunSummary,
RoutineTrigger,
} from "@paperclipai/shared";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import { CalendarClock, Clock3, Play, Repeat, UserRound } from "lucide-react";
import { IssueRow } from "@/components/IssueRow";
import { MarkdownBody } from "@/components/MarkdownBody";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb";
import { Link } from "@/lib/router";
import {
routineActivityAuditHref,
routineDetailHref,
routineRunsAuditHref,
} from "./RoutineContextualSidebar";
import { useRoutineDetail } from "./routine-sections/context";
export type RoutineScheduleSummary = {
label: string;
detail: string;
nextRunAt: Date | null;
};
export function formatRoutineTimestamp(value: Date | string) {
return new Date(value).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function summarizeRoutineSchedule(triggers: RoutineTrigger[]): RoutineScheduleSummary {
const schedules = triggers.filter((trigger) => trigger.kind === "schedule" && trigger.enabled);
const nextRunAt = schedules
.map((trigger) => trigger.nextRunAt ? new Date(trigger.nextRunAt) : null)
.filter((value): value is Date => value !== null && Number.isFinite(value.getTime()))
.sort((left, right) => left.getTime() - right.getTime())[0] ?? null;
if (schedules.length === 0) {
return { label: "No active schedule", detail: "Manual runs only", nextRunAt: null };
}
const first = schedules[0]!;
return {
label: schedules.length === 1 ? "1 active schedule" : `${schedules.length} active schedules`,
detail: first.cronExpression
? `${first.cronExpression}${first.timezone ? ` · ${first.timezone}` : ""}`
: first.label ?? "Scheduled trigger",
nextRunAt,
};
}
function normalizeIssueStatus(value: string): IssueStatus {
return ISSUE_STATUSES.includes(value as IssueStatus) ? value as IssueStatus : "todo";
}
function normalizeIssuePriority(value: string): IssuePriority {
return ISSUE_PRIORITIES.includes(value as IssuePriority) ? value as IssuePriority : "medium";
}
/** Display-only adapter for the canonical task row; run summaries intentionally carry compact task data. */
export function routineRunIssue(
summary: NonNullable<RoutineRunSummary["linkedIssue"]>,
run: RoutineRunSummary,
companyId: string,
projectId: string | null,
): Issue {
return {
...summary,
companyId,
projectId,
projectWorkspaceId: null,
goalId: null,
parentId: null,
description: null,
status: normalizeIssueStatus(summary.status),
workMode: "standard",
priority: normalizeIssuePriority(summary.priority),
reviewPolicy: null,
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: null,
issueNumber: null,
originKind: "routine_execution",
originId: run.routineId,
originRunId: run.id,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: run.triggeredAt,
};
}
function OverviewFact({
icon: Icon,
label,
value,
detail,
}: {
icon: typeof Clock3;
label: string;
value: React.ReactNode;
detail?: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1 rounded-lg border border-border p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{label}</span>
</div>
<div className="min-w-0 text-sm font-medium text-foreground">{value}</div>
{detail ? <div className="min-w-0 text-xs text-muted-foreground">{detail}</div> : null}
</div>
);
}
export function RoutineOverview() {
const { routine, routineRuns, currentAssignee, hasLiveRun } = useRoutineDetail();
const schedule = summarizeRoutineSchedule(routine.triggers);
const sortedRuns = [...(routineRuns ?? [])].sort(
(left, right) => new Date(right.triggeredAt).getTime() - new Date(left.triggeredAt).getTime(),
);
const lastRun = sortedRuns[0] ?? null;
const recentRuns = sortedRuns.slice(0, 5);
const detailOrigin = createIssueDetailLocationState(
routine.title,
routineDetailHref(routine.id),
"issues",
);
const automationState = routine.status === "archived"
? "archived"
: !routine.assigneeAgentId
? "draft"
: routine.status;
return (
<div className="flex flex-col gap-6" data-routine-overview-mode="read">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<OverviewFact
icon={Repeat}
label="State"
value={<StatusBadge status={automationState} />}
detail={hasLiveRun ? "A run is active now" : "No active run"}
/>
<OverviewFact
icon={CalendarClock}
label="Schedule"
value={schedule.label}
detail={<span className="font-mono">{schedule.detail}</span>}
/>
<OverviewFact
icon={Clock3}
label="Next run"
value={schedule.nextRunAt ? formatRoutineTimestamp(schedule.nextRunAt) : "Not scheduled"}
detail={schedule.nextRunAt ? "Scheduled" : "Add or enable a schedule"}
/>
<OverviewFact
icon={Play}
label="Last run"
value={lastRun ? <StatusBadge status={lastRun.status} /> : "No runs yet"}
detail={lastRun ? formatRoutineTimestamp(lastRun.triggeredAt) : "Run manually or wait for the schedule"}
/>
</div>
<section className="flex flex-col gap-2" aria-labelledby="routine-agent-heading">
<h2 id="routine-agent-heading" className="text-sm font-semibold">Default agent</h2>
{currentAssignee ? (
<Link
to={`/agents/${currentAssignee.urlKey ?? currentAssignee.id}`}
className="flex w-fit items-center gap-2 rounded-md text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserRound className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{currentAssignee.name}
</Link>
) : (
<p className="text-sm text-muted-foreground">No default agent. Automatic triggers remain paused.</p>
)}
</section>
<section className="flex flex-col gap-2" aria-labelledby="routine-description-heading">
<h2 id="routine-description-heading" className="text-sm font-semibold">Description</h2>
{routine.description?.trim() ? (
<MarkdownBody className="text-sm text-foreground" linkIssueReferences>
{routine.description}
</MarkdownBody>
) : (
<p className="text-sm text-muted-foreground">No description yet.</p>
)}
</section>
<section className="flex flex-col gap-2" aria-labelledby="routine-recent-runs-heading">
<div className="flex items-center justify-between gap-3">
<h2 id="routine-recent-runs-heading" className="text-sm font-semibold">Recent runs</h2>
<Button variant="ghost" size="sm" asChild>
<Link to={routineRunsAuditHref(routine.id)}>View all runs</Link>
</Button>
</div>
{recentRuns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No runs yet. Run the routine now or wait for its schedule.
</p>
) : (
<div className="flex flex-col gap-0.5">
{recentRuns.map((run) => run.linkedIssue ? (
<IssueRow
key={run.id}
issue={routineRunIssue(run.linkedIssue, run, routine.companyId, routine.projectId)}
issueLinkState={detailOrigin}
presentation="task"
metadata={(
<span className="flex items-center gap-2">
<StatusBadge status={run.status} />
<span className="font-mono text-xs text-muted-foreground">{formatRoutineTimestamp(run.triggeredAt)}</span>
</span>
)}
/>
) : (
<div key={run.id} className="flex min-w-0 items-center gap-2 rounded-lg px-2 py-2 text-sm">
<StatusBadge status={run.status} />
<span className="min-w-0 flex-1 truncate">{run.trigger?.label ?? "Routine run"}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{formatRoutineTimestamp(run.triggeredAt)}</span>
</div>
))}
</div>
)}
<Button variant="link" size="sm" className="w-fit px-0" asChild>
<Link to={routineActivityAuditHref(routine.id)}>View routine activity</Link>
</Button>
</section>
</div>
);
}

View File

@ -34,8 +34,8 @@ function DormantPill() {
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${DORMANT_GRADIENT_ID})`} />
{/* Closed eyes: the same rounded rects as the open pair, flattened. */}
<rect x="75.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<rect x="28.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<rect x="75.9199" y="66.3047" width="9" height="4" rx="2" fill="var(--pill-guy-eye)" />
<rect x="28.9199" y="66.3047" width="9" height="4" rx="2" fill="var(--pill-guy-eye)" />
<defs>
<linearGradient
id={DORMANT_GRADIENT_ID}
@ -45,8 +45,8 @@ function DormantPill() {
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#626262" />
<stop offset="1" stopColor="#101010" />
<stop stopColor="var(--pill-guy-dormant-top)" />
<stop offset="1" stopColor="var(--pill-guy-dormant-bottom)" />
</linearGradient>
</defs>
</svg>
@ -57,9 +57,9 @@ function AlivePill() {
return (
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${ALIVE_GRADIENT_ID})`} />
<rect x="76.1406" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<rect x="28.8301" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<path d={TUFT_PATH} fill="#2D200D" />
<rect x="76.1406" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="var(--pill-guy-eye)" />
<rect x="28.8301" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="var(--pill-guy-eye)" />
<path d={TUFT_PATH} fill="var(--pill-guy-tuft)" />
<defs>
<linearGradient
id={ALIVE_GRADIENT_ID}
@ -69,8 +69,8 @@ function AlivePill() {
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3028AA" />
<stop offset="1" stopColor="#FF0000" />
<stop stopColor="var(--pill-guy-alive-top)" />
<stop offset="1" stopColor="var(--pill-guy-alive-bottom)" />
</linearGradient>
</defs>
</svg>

View File

@ -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<string, string> = {
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 (
<div className="space-y-6">
{/* Assignment row */}
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={editDraft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={(assigneeAgentId) =>
setEditDraft((current) => ({ ...current, assigneeAgentId }))
}
onConfirm={() => {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Responsible</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? (
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={editDraft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "var(--project-none)" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "var(--project-none)" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
{!routine.assigneeAgentId ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
Default agent required. This routine can stay as a draft and still run manually, but
automation stays paused until you assign a default agent.
</div>
) : null}
{/* Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-end">
{routine.descriptionDocument ? (
<DocumentAnnotationsCountChip
issueId={routine.id}
docKey="description"
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
panelOpen={descriptionAnnotationsOpen}
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
/>
) : null}
</div>
{routine.descriptionDocument ? (
<IssueDocumentAnnotations
issueId={routine.id}
doc={routine.descriptionDocument}
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
bodyMarkdown={editDraft.description}
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
draftConflicted={saveConflict}
historicalPreview={false}
locationHash={typeof window === "undefined" ? "" : window.location.hash}
panelOpen={descriptionAnnotationsOpen}
onPanelOpenChange={setDescriptionAnnotationsOpen}
>
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => 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();
}
}}
/>
</IssueDocumentAnnotations>
) : (
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => 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();
}
}}
/>
)}
</div>
{/* Variables peek */}
<div className="space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-3">
<SummaryCard
icon={Clock3}
label="Triggers"
value={activeTriggers === 0 ? "None" : `${activeTriggers} active`}
hint={nextFire ? `Next fire ${nextFire}` : "No schedule"}
to={() => navigateToSection("triggers")}
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
/>
<SummaryCard
icon={KeyRound}
label="Secrets"
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
hint="Manage bound secrets"
to={() => navigateToSection("secrets")}
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
/>
<SummaryCard
icon={Play}
label="Last run"
value={lastRun ? lastRun.status.replaceAll("_", " ") : "No runs"}
hint={lastRun ? timeAgo(lastRun.triggeredAt) : "Trigger a run"}
to={() => navigateToSection("runs")}
ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."}
/>
</div>
{/* Recent activity */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent activity
</p>
{recentActivity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="divide-y divide-border/60">
{recentActivity.map((event) => (
<div key={event.id} className="flex items-center gap-2 py-1.5 text-xs">
<Badge variant="outline" className="shrink-0 font-mono">
{event.action}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{event.details && Object.keys(event.details).length > 0
? Object.keys(event.details).slice(0, 3).join(" · ")
: ""}
</span>
<span className="shrink-0 text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
</div>
))}
<button
type="button"
onClick={() => navigateToSection("activity")}
className="flex items-center gap-1 pt-2 text-xs text-muted-foreground hover:text-foreground"
>
View all activity <ArrowRight className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
);
}
function SummaryCard({
icon: Icon,
label,
value,
hint,
to,
ariaLabel,
}: {
icon: typeof Clock3;
label: string;
value: string;
hint: string;
to: () => void;
ariaLabel: string;
}) {
return (
<button type="button" onClick={to} aria-label={ariaLabel} className="text-left">
<Card className="gap-2 p-4 transition-colors hover:border-border hover:bg-accent/30">
<CardContent className="space-y-1 p-0">
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
<ArrowRight className="ml-auto h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<p className="text-lg font-semibold">{value}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</CardContent>
</Card>
</button>
);
}
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 (
<div className="space-y-4">
{/* Add-trigger drawer header (§3.2) */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">
{routine.triggers.length === 0
? "No triggers yet"
: `${routine.triggers.length} trigger${routine.triggers.length === 1 ? "" : "s"}`}
</p>
<Button
size="sm"
variant={addOpen ? "secondary" : "default"}
onClick={() => setAddOpen((open) => !open)}
aria-expanded={addOpen}
>
{addOpen ? (
<>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</>
) : (
<>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New trigger
</>
)}
</Button>
</div>
{/* Add trigger form — expand-on-click drawer */}
{addOpen ? (
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">Add trigger</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Kind</Label>
<Select
value={newTrigger.kind}
onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
{kind}
{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newTrigger.kind === "schedule" && (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={newTrigger.cronExpression}
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
onValidityChange={setNewScheduleEditorValid}
/>
</div>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select
value={newTrigger.signingMode}
onValueChange={(signingMode) =>
setNewTrigger((current) => ({ ...current, signingMode }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={newTrigger.replayWindowSec}
onChange={(event) =>
setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))
}
/>
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={() =>
createTrigger.mutate(undefined, {
onSuccess: () => {
setNewTrigger(createDefaultNewTrigger());
setAddOpen(false);
},
})
}
disabled={addDisabled}
>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
) : null}
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<EmptyState
icon={Clock3}
message="No triggers yet."
action="Add a schedule"
onAction={() => setAddOpen(true)}
/>
) : (
<div className="space-y-3">
{routine.triggers.map((trigger) => (
<RoutineTriggerCard
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
onDelete={(id) => deleteTrigger.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
export function VariablesSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, navigateToSection } = ctx;
const hasVariables = editDraft.variables.length > 0;
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs">
<span className="flex-1 text-muted-foreground">
Variables are auto-detected from <code className="font-mono">{"{{placeholders}}"}</code> in
the title &amp; instructions. The variable name is read-only rename by editing the
placeholder.
</span>
<Button variant="secondary" size="sm" onClick={() => navigateToSection("overview")}>
<Edit3 className="mr-1.5 h-3.5 w-3.5" />
Edit instructions
</Button>
</div>
{hasVariables ? (
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
) : (
<EmptyState
icon={Braces}
message="No variables yet. Add a {{placeholder}} in the title or instructions to create one."
action="Edit instructions"
onAction={() => navigateToSection("overview")}
/>
)}
</div>
);
}
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 (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
Routine secrets apply to every task this routine creates. They override matching keys in
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
</div>
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<EnvironmentVariablesEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
secrets={availableSecrets}
recentlyUsedSecrets={recentlyUsedSecrets}
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))}
/>
</div>
);
}
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 (
<div className="space-y-6">
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Concurrency
</p>
<RadioCardGroup
ariaLabel="Concurrency policy"
value={editDraft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) =>
setEditDraft((current) => ({ ...current, concurrencyPolicy }))
}
options={concurrencyPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Catch-up
</p>
<RadioCardGroup
ariaLabel="Catch-up policy"
value={editDraft.catchUpPolicy}
onValueChange={(catchUpPolicy) =>
setEditDraft((current) => ({ ...current, catchUpPolicy }))
}
options={catchUpPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Advanced run policy
</p>
<RadioCardGroup
ariaLabel="Advanced run policy"
value={editDraft.activityGatePolicy}
onValueChange={(activityGatePolicy) =>
setEditDraft((current) => ({ ...current, activityGatePolicy }))
}
options={activityGatePolicyOptions}
disabled={!hasScheduleTrigger}
/>
{!hasScheduleTrigger ? (
<p className="text-xs text-muted-foreground">
Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always
run.
</p>
) : gateEnabled ? (
<div className="space-y-2 rounded-lg border border-border p-3">
<Label className="text-xs font-medium">Activity scope</Label>
<RadioCardGroup
ariaLabel="Activity gate scope"
value={editDraft.activityGateScope}
onValueChange={(activityGateScope) =>
setEditDraft((current) => ({ ...current, activityGateScope }))
}
options={activityGateScopeOptions}
/>
</div>
) : null}
</div>
<NextFiresPreview
triggers={routine.triggers}
concurrencyPolicy={editDraft.concurrencyPolicy}
/>
</div>
);
}
const dispositionToneClass: Record<string, string> = {
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 (
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Next 5 fires
</p>
{preview ? (
<>
<div className="space-y-1.5 rounded-lg border border-border p-3 font-mono text-xs">
{preview.entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-muted-foreground/40">·</span>
<span className="tabular-nums">{formatFireTime(entry.at, preview.timeZone)}</span>
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground/50" />
<span className={cn("font-medium", dispositionToneClass[entry.disposition])}>
{entry.label}
</span>
{entry.note ? (
<span className="truncate text-muted-foreground/60">({entry.note})</span>
) : null}
</div>
))}
</div>
<p className="text-(length:--text-micro) text-muted-foreground/60">
Preview assumes the previous run is still in flight when the next fires. Times shown in{" "}
{preview.timeZone}.
</p>
</>
) : (
<p className="rounded-lg border border-dashed border-border p-3 text-xs text-muted-foreground">
No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy
treats upcoming fires.
</p>
)}
</div>
);
}
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();
}
}

View File

@ -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({
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
<SummaryCard
icon={Clock3}
label="Triggers"
@ -329,14 +327,6 @@ export function OverviewSection({
to={() => navigateToSection("triggers")}
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
/>
<SummaryCard
icon={KeyRound}
label="Secrets"
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
hint="Manage bound secrets"
to={() => navigateToSection("secrets")}
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
/>
<SummaryCard
icon={Play}
label="Last run"

View File

@ -78,6 +78,10 @@ vi.mock("../components/MarkdownEditor", () => ({
},
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown-body">{children}</div>,
}));
// 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<HTMLTextAreaElement>('[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<HTMLTextAreaElement>('[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<HTMLTextAreaElement>('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<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("raw instructions");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AgentDetail, AgentRuntimeState, HeartbeatRun, Issue } from "@paperclipai/shared";
import { describe, expect, it, vi } from "vitest";
import { AgentOverview } from "./AgentDetail";
vi.mock("@/lib/router", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => (
<a href={to} {...props}>{children}</a>
),
};
});
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
}));
describe("AgentOverview", () => {
it("prioritizes identity, capability, runtime, skills, tasks, and scoped Audit entry points", () => {
const agent = {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
urlKey: "codexcoder",
role: "engineer",
title: "Product engineer",
status: "active",
reportsTo: null,
capabilities: "Builds and verifies product changes.",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.6-sol" },
runtimeConfig: {},
chainOfCommand: [],
access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] },
} as unknown as AgentDetail;
const issue = {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-42",
title: "Simplify agent information architecture",
status: "in_progress",
priority: "high",
updatedAt: new Date("2026-08-31T12:00:00Z"),
} as unknown as Issue;
const runtime = {
sessionDisplayId: "codex-session-42",
} as unknown as AgentRuntimeState;
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<AgentOverview
agent={agent}
runs={[] as HeartbeatRun[]}
assignedIssues={[issue]}
runtimeState={runtime}
directReportCount={2}
skillNames={["Design Guide", "Check PR"]}
agentRouteId="codexcoder"
/>
</QueryClientProvider>,
);
expect(markup).toContain("Identity");
expect(markup).toContain("Capabilities");
expect(markup).toContain("Harness / Runtime");
expect(markup).toContain("Design Guide");
expect(markup).toContain("Simplify agent information architecture");
expect(markup).toContain("PAP-42");
expect(markup).toContain('href="/activity/costs?agentId=agent-1"');
expect(markup).not.toContain("Run Activity");
expect(markup).not.toContain("Tasks by Status");
});
});

View File

@ -0,0 +1,874 @@
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { builtInAgentsApi, type BuiltInAgentState } from "../api/builtInAgents";
import { environmentsApi } from "../api/environments";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { isPlatformManagedEnvironment } from "../lib/managed-sandbox-environment";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
// Lazy-loaded so the roster page doesn't statically pull in the full
// AgentConfigForm module graph (the modal reuses its adapter/model pickers).
const ConfigureBuiltInAgentModal = lazy(() =>
import("../components/ConfigureBuiltInAgentModal").then((m) => ({
default: m.ConfigureBuiltInAgentModal,
})),
);
export const AGENT_FILTER_TABS = ["all", "active", "paused", "error", "builtin"] as const;
type FilterTab = (typeof AGENT_FILTER_TABS)[number];
const AGENT_FILTER_TAB_ITEMS: { value: FilterTab; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "error", label: "Error" },
{ value: "builtin", label: "Built-in" },
];
function isFilterTab(value: string): value is FilterTab {
return (AGENT_FILTER_TABS as readonly string[]).includes(value);
}
interface EnvironmentDescriptor {
label: string;
detail: string;
title: string;
}
const localEnvironmentDescriptor: EnvironmentDescriptor = {
label: "Local",
detail: "Paperclip host",
title: "Local - Paperclip host",
};
const loadingEnvironmentDescriptor: EnvironmentDescriptor = {
label: "—",
detail: "Loading environment",
title: "Loading environment",
};
// Agents in these states never appear in the agents list — `terminated` is
// hidden like an archived company, and `pending_approval` is a hiring gate that
// lives in the task thread, not an agent run state (PAP-75).
const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]);
function matchesFilter(status: string, tab: FilterTab): boolean {
if (tab === "all") return true;
if (tab === "active") return status === "active" || status === "running" || status === "idle";
if (tab === "paused") return status === "paused";
if (tab === "error") return status === "error";
return true;
}
function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set<string>): Agent[] {
return agents
.filter((a) => {
if (HIDDEN_AGENT_STATUSES.has(a.status)) return false;
// The `builtin` filter keys on the built-in marker, not agent status.
if (tab === "builtin") return builtInAgentIds.has(a.id);
return matchesFilter(a.status, tab);
})
.sort((a, b) => a.name.localeCompare(b.name));
}
function getConfiguredModel(agent: Agent): string | null {
const value = agent.adapterConfig?.model;
if (typeof value !== "string") return null;
const model = value.trim();
return model.length > 0 ? model : null;
}
function formatEnvironmentDriver(driver: Environment["driver"]): string {
if (driver === "ssh") return "SSH";
return driver.charAt(0).toUpperCase() + driver.slice(1);
}
function getSandboxProviderLabel(
environment: Environment,
capabilities?: EnvironmentCapabilities | null,
): string {
const provider = typeof environment.config.provider === "string"
? environment.config.provider.trim()
: "";
if (!provider) return "Sandbox";
return capabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
}
function describeEnvironment(
environment: Environment,
capabilities?: EnvironmentCapabilities | null,
): EnvironmentDescriptor {
const detail = isPlatformManagedEnvironment(environment)
? "Managed by Paperclip"
: environment.driver === "sandbox"
? `${getSandboxProviderLabel(environment, capabilities)} sandbox provider`
: environment.driver === "local"
? "Paperclip host"
: formatEnvironmentDriver(environment.driver);
return {
label: environment.name,
detail,
title: `${environment.name} - ${detail}`,
};
}
function describeMissingEnvironment(environmentId: string): EnvironmentDescriptor {
return {
label: "Unknown environment",
detail: environmentId.slice(0, 8),
title: `Unknown environment - ${environmentId}`,
};
}
function resolveAgentEnvironment(
agent: Agent,
environmentsById: Map<string, Environment>,
instanceDefaultEnvironmentId: string | null,
capabilities?: EnvironmentCapabilities | null,
): EnvironmentDescriptor {
const environmentId = agent.defaultEnvironmentId ?? instanceDefaultEnvironmentId;
if (!environmentId) return localEnvironmentDescriptor;
const environment = environmentsById.get(environmentId);
return environment
? describeEnvironment(environment, capabilities)
: describeMissingEnvironment(environmentId);
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<string>): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds);
// Hidden agents (terminated / pending_approval) never render as a row, but
// any visible reports are promoted so the tree doesn't lose live agents.
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
acc.push(...filteredReports);
return acc;
}
const nodeMatches = tab === "builtin"
? builtInAgentIds.has(node.id)
: matchesFilter(node.status, tab);
if (nodeMatches || filteredReports.length > 0) {
acc.push({ ...node, reports: filteredReports });
}
return acc;
}, [])
.sort((a, b) => a.name.localeCompare(b.name));
}
export function Agents() {
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const location = useLocation();
const { isMobile } = useSidebar();
const pathSegment = location.pathname.split("/").pop() ?? "all";
const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "all";
const [view, setView] = useState<"list" | "org">("org");
const forceListView = isMobile;
const effectiveView: "list" | "org" = forceListView ? "list" : view;
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
enabled: !!selectedCompanyId,
});
const builtInAgentsEnabled = instanceSettings?.experimental.enableBuiltInAgents === true;
const tab: FilterTab = requestedTab === "builtin" && !builtInAgentsEnabled ? "all" : requestedTab;
const visibleTabItems = useMemo(
() => AGENT_FILTER_TAB_ITEMS.filter((item) => item.value !== "builtin" || builtInAgentsEnabled),
[builtInAgentsEnabled],
);
const { data: builtInAgents } = useQuery({
queryKey: queryKeys.builtInAgents.list(selectedCompanyId!),
queryFn: () => builtInAgentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && builtInAgentsEnabled,
});
const builtInByAgentId = useMemo(() => {
const map = new Map<string, BuiltInAgentState>();
if (!builtInAgentsEnabled) return map;
for (const entry of builtInAgents ?? []) {
if (entry.agentId) map.set(entry.agentId, entry);
}
return map;
}, [builtInAgents, builtInAgentsEnabled]);
const builtInAgentIds = useMemo(() => new Set(builtInByAgentId.keys()), [builtInByAgentId]);
const [configureState, setConfigureState] = useState<BuiltInAgentState | null>(null);
const { data: agents, isLoading, error } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: orgTree } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId && effectiveView === "org",
});
const environmentsEnabled = instanceSettings?.experimental.enableEnvironments === true;
const { data: environments } = useQuery({
queryKey: queryKeys.environments.list(selectedCompanyId!),
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const { data: environmentCapabilities } = useQuery({
queryKey: queryKeys.environments.capabilities(selectedCompanyId!),
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const runsQueryKey = [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"] as const;
const sharedRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs:agents-page",
queryKey: runsQueryKey,
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
leaderOnly: true,
});
const { data: runs, dataUpdatedAt: runsUpdatedAt } = useQuery({
queryKey: runsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedRuns.enabled,
refetchInterval: sharedRuns.refetchInterval,
});
usePublishSharedQueryData(sharedRuns, runs, runsUpdatedAt);
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
// Map agentId -> first live run + live run count
const liveRunByAgent = useMemo(() => {
const map = new Map<string, { runId: string; liveCount: number }>();
for (const r of runs ?? []) {
if (r.status !== "running" && r.status !== "queued") continue;
const existing = map.get(r.agentId);
if (existing) {
existing.liveCount += 1;
continue;
}
map.set(r.agentId, { runId: r.id, liveCount: 1 });
}
return map;
}, [runs]);
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const environmentsById = useMemo(() => {
const map = new Map<string, Environment>();
for (const environment of environments ?? []) map.set(environment.id, environment);
return map;
}, [environments]);
const environmentByAgentId = useMemo(() => {
const map = new Map<string, EnvironmentDescriptor>();
for (const agent of agents ?? []) {
map.set(
agent.id,
resolveAgentEnvironment(
agent,
environmentsById,
instanceSettings?.defaultEnvironmentId ?? null,
environmentCapabilities,
),
);
}
return map;
}, [agents, environmentsById, environmentCapabilities, instanceSettings?.defaultEnvironmentId]);
useEffect(() => {
setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]);
useEffect(() => {
if (selectedCompanyId && requestedTab === "builtin" && instanceSettings && !builtInAgentsEnabled) {
navigate("/agents/all", { replace: true });
}
}, [builtInAgentsEnabled, instanceSettings, navigate, requestedTab, selectedCompanyId]);
if (!selectedCompanyId) {
return <EmptyState icon={Bot} message="Select a company to view agents." />;
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds);
const environmentDataLoading = environmentsEnabled && environments === undefined;
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
const resolveRenderedEnvironment = (agentId: string) => (
environmentDataLoading
? loadingEnvironmentDescriptor
: environmentByAgentId.get(agentId) ?? localEnvironmentDescriptor
);
const renderAgentRow = (agent: Agent) => {
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
const agentPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined;
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const builtInState = builtInByAgentId.get(agent.id);
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
// Lifecycle chip + inline `Set up`. Rendered inline in
// `meta` at xl (where there's room and the meta columns align) and on a
// dedicated full-width line beneath the name below xl, so the chips never
// starve the name — the row's primary identifier — at narrow widths.
const builtInCluster = builtInState && showBuiltInLifecycle ? (
<>
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button
size="xs"
variant="outline"
onClick={() => setConfigureState(builtInState)}
>
Set up
</Button>
</span>
)}
</>
) : null;
return (
<EntityRow
key={agent.id}
title={agent.name}
// Fixed (truncating) title width at xl so the `meta` group starts at a
// constant x on every row — that's what makes the model + timestamp
// columns line up vertically. Below xl the meta columns are hidden, so
// the title flexes instead: a fixed width there let the shrink-0
// trailing actions squeeze the name to zero width on mobile.
titleClassName="flex-1 xl:flex-none xl:w-56"
titleTextClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
subtitleClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={cn(
"group",
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "sm:text-foreground/55" : "",
)}
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={agent.status} />
)}
secondaryRow={
builtInCluster ? (
<div className="xl:hidden flex flex-wrap items-center gap-1.5">
{builtInCluster}
</div>
) : undefined
}
meta={
<div className="flex items-center gap-3">
{builtInCluster && (
<div className="hidden xl:flex items-center gap-1.5">
{builtInCluster}
</div>
)}
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns
agent={agent}
environment={resolveRenderedEnvironment(agent.id)}
showEnvironment={showEnvironmentColumn}
/>
</div>
</div>
}
metaSpacerClassName="hidden xl:block"
trailing={
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
{/* Row actions mirror the agent detail page; stop the click
from bubbling to the row link so buttons don't navigate.
Hidden on mobile so the agent name keeps room to render. */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AgentActionButtons
agent={agent}
companyId={selectedCompanyId}
runLabel="Run Heartbeat"
showStatus={false}
/>
</div>
<StarToggle
size="row"
starred={agentStarred}
pending={agentStarPending}
resourceName={agent.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
starred: next,
})}
/>
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={agentJoinLeavePending}
pendingState={agentJoinLeavePending ? membershipMutation.variables?.state ?? null : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
})}
/>
</div>
}
/>
);
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
<PageTabBar
items={visibleTabItems}
value={tab}
onValueChange={(v) => navigate(`/agents/${v}`)}
/>
</Tabs>
<div className="flex items-center gap-2">
{/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border" role="group" aria-label="View mode">
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setView("list")}
title="List view"
aria-label="List view"
aria-pressed={effectiveView === "list"}
>
<List className="h-3.5 w-3.5" />
</button>
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setView("org")}
title="Org chart view"
aria-label="Org chart view"
aria-pressed={effectiveView === "org"}
>
<GitBranch className="h-3.5 w-3.5" />
</button>
</div>
)}
<Button size="sm" variant="outline" onClick={openNewAgent}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Agent
</Button>
</div>
</div>
{filtered.length > 0 && (
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
)}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{agents && agents.length === 0 && (
<EmptyState
icon={Bot}
message="Create your first agent to get started."
action="New Agent"
onAction={openNewAgent}
/>
)}
{/* List view */}
{effectiveView === "list" && filtered.length > 0 && (
<div>
{filtered.map(renderAgentRow)}
</div>
)}
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected status.
</p>
)}
{/* Org chart view */}
{effectiveView === "org" && filteredOrg.length > 0 && (
<div className="py-1">
{filteredOrg.map((node) => (
<OrgTreeNode
key={node.id}
node={node}
depth={0}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
environmentByAgentId={environmentByAgentId}
environmentDataLoading={environmentDataLoading}
showEnvironment={showEnvironmentColumn}
tab={tab}
memberships={membershipsQuery.data}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={setConfigureState}
/>
))}
</div>
)}
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected status.
</p>
)}
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No organizational hierarchy defined.
</p>
)}
{configureState && selectedCompanyId && (
<Suspense fallback={null}>
<ConfigureBuiltInAgentModal
companyId={selectedCompanyId}
state={configureState}
open={configureState !== null}
onOpenChange={(open) => {
if (!open) setConfigureState(null);
}}
/>
</Suspense>
)}
</div>
);
}
function OrgTreeNode({
node,
depth,
agentMap,
liveRunByAgent,
environmentByAgentId,
environmentDataLoading,
showEnvironment,
tab,
memberships,
membershipMutation,
builtInByAgentId,
onConfigureBuiltIn,
}: {
node: OrgNode;
depth: number;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
environmentByAgentId: Map<string, EnvironmentDescriptor>;
environmentDataLoading: boolean;
showEnvironment: boolean;
tab: FilterTab;
memberships: ReturnType<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
builtInByAgentId: Map<string, BuiltInAgentState>;
onConfigureBuiltIn: (state: BuiltInAgentState) => void;
}) {
const agent = agentMap.get(node.id);
const builtInState = builtInByAgentId.get(node.id);
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
const membershipState = resourceMembershipState(memberships, "agent", node.id);
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(memberships, "agent", node.id);
return (
<div style={{ paddingLeft: depth * 24 }}>
<Link
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 transition-colors w-full text-left no-underline text-inherit",
agent?.pausedAt && tab !== "paused" && "opacity-50",
membershipState === "left" && "sm:text-foreground/55",
)}
>
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={node.status} />
)}
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-2">
{/* Name floor + `truncate` keeps the primary identifier readable; the
cluster wraps to a second line under pressure instead of starving
the name at narrow widths. */}
<div className="min-w-(--sz-7rem) truncate">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
{agent?.title ? ` - ${agent.title}` : ""}
</span>
</div>
{builtInState && showBuiltInLifecycle && (
<div className="flex items-center gap-1.5 shrink-0">
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button size="xs" variant="outline" onClick={() => onConfigureBuiltIn(builtInState)}>
Set up
</Button>
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="sm:hidden">
{liveRunByAgent.has(node.id) ? (
<LiveRunIndicator
agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
) : (
<AgentStatusBadge status={node.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(node.id) && (
<LiveRunIndicator
agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
)}
{agent && (
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns
agent={agent}
environment={
environmentDataLoading
? loadingEnvironmentDescriptor
: environmentByAgentId.get(agent.id) ?? localEnvironmentDescriptor
}
showEnvironment={showEnvironment}
/>
</div>
)}
<span className="w-20 flex justify-end">
<AgentStatusBadge status={node.status} />
</span>
</div>
<MembershipAction
state={membershipState}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "left",
})}
/>
<div className="hidden sm:flex items-center gap-3">
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={node.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
starred: next,
})}
/>
</div>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
<div className="border-l border-border ml-4">
{node.reports.map((child) => (
<OrgTreeNode
key={child.id}
node={child}
depth={depth + 1}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
environmentByAgentId={environmentByAgentId}
environmentDataLoading={environmentDataLoading}
showEnvironment={showEnvironment}
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={onConfigureBuiltIn}
/>
))}
</div>
)}
</div>
);
}
/**
* Provider/model + heartbeat columns shared by the list and org views. The
* model and adapter label share one fixed-width cell, each line truncating with
* an ellipsis so a long model id can never overlap the heartbeat column. The
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
* date like "Apr 30, 2026".
*/
function AgentMetaColumns({
agent,
environment,
showEnvironment,
}: {
agent: Agent;
environment: EnvironmentDescriptor;
showEnvironment: boolean;
}) {
const model = getConfiguredModel(agent);
const adapterLabel = getAdapterLabel(agent.adapterType);
return (
<>
<div className="w-44 min-w-0 leading-tight">
<div
className="truncate font-mono text-xs text-muted-foreground"
title={model ?? undefined}
>
{model ?? "—"}
</div>
<div className="truncate font-mono text-(length:--text-micro) text-muted-foreground/70" title={adapterLabel}>
{adapterLabel}
</div>
</div>
{showEnvironment && (
<div className="w-44 min-w-0 leading-tight">
<div className="truncate text-xs text-muted-foreground" title={environment.title}>
{environment.label}
</div>
<div className="truncate text-(length:--text-micro) text-muted-foreground/70">
{environment.detail}
</div>
</div>
)}
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
</>
);
}
function LiveRunIndicator({
agentRef,
runId,
liveCount,
}: {
agentRef: string;
runId: string;
liveCount: number;
}) {
return (
<Link
to={`/agents/${agentRef}/runs/${runId}`}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
onClick={(e) => e.stopPropagation()}
>
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-(length:--text-micro) font-medium text-blue-600 dark:text-blue-400">
Live{liveCount > 1 ? ` (${liveCount})` : ""}
</span>
</Link>
);
}

View File

@ -381,6 +381,54 @@ describe("Agents", () => {
expect(heartbeatCell?.textContent).not.toContain("\n");
});
it("switches between the preserved list and the interactive org chart with icon buttons", async () => {
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
const listToggle = container.querySelector<HTMLButtonElement>('button[aria-label="List view"]');
const orgToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Org chart view"]');
expect(listToggle?.getAttribute("aria-pressed")).toBe("true");
expect(orgToggle?.getAttribute("aria-pressed")).toBe("false");
expect(orgToggle?.querySelector(".lucide-network")).not.toBeNull();
expect(orgToggle?.querySelector(".lucide-git-branch")).toBeNull();
expect(container.querySelector('[data-testid="org-chart-viewport"]')).toBeNull();
await act(async () => {
orgToggle?.click();
});
await flushReact();
await flushReact();
expect(mockAgentsApi.org).toHaveBeenCalledWith("company-1");
expect(orgToggle?.getAttribute("aria-pressed")).toBe("true");
const orgViewport = container.querySelector('[data-testid="org-chart-viewport"]');
expect(orgViewport).not.toBeNull();
expect(orgViewport?.parentElement?.classList.contains("flex-1")).toBe(true);
expect(orgViewport?.parentElement?.classList.contains("md:min-h-0")).toBe(true);
expect(orgViewport?.parentElement?.classList.contains("h-(--sz-calc-38)")).toBe(false);
expect(orgViewport?.parentElement?.parentElement?.classList.contains("h-full")).toBe(true);
expect(orgViewport?.parentElement?.parentElement?.classList.contains("min-h-0")).toBe(true);
expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Fit chart to screen"]')).not.toBeNull();
await act(async () => {
listToggle?.click();
});
await flushReact();
expect(container.querySelector('[data-testid="org-chart-viewport"]')).toBeNull();
expect(container.textContent).toContain("gpt-5.4");
});
it("gives mobile agent names the full row width after the leading status indicator", async () => {
mockSidebarState.isMobile = true;
mockResourceMembershipsApi.listMine.mockResolvedValue({
@ -901,7 +949,7 @@ describe("Agents", () => {
await flushReact();
await flushReact();
// Switch from the default org view to the list view.
// Keep the list view selected before checking its aligned metadata columns.
const listToggle = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.querySelector("svg.lucide-list"),
);
@ -938,7 +986,7 @@ describe("Agents", () => {
await flushReact();
await flushReact();
// Org view (default).
// List view (default).
const orgAction = container.querySelector('[aria-label="Leave Alpha"]');
const orgStar = container.querySelector('[aria-label="Star Alpha"]');
expect(orgAction).not.toBeNull();
@ -946,7 +994,7 @@ describe("Agents", () => {
expect(orgAction?.closest(".hidden")).toBeNull();
expect(orgStar?.closest(".hidden")).not.toBeNull();
// List view.
// List view remains stable after explicitly selecting it.
const listToggle = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.querySelector("svg.lucide-list"),
);

View File

@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { queryKeys } from "../lib/queryKeys";
import { isPlatformManagedEnvironment } from "../lib/managed-sandbox-environment";
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
@ -21,11 +22,12 @@ import { EntityRow } from "../components/EntityRow";
import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { OrgChart } from "./OrgChart";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
import { AlertTriangle, Bot, Plus, List, Network } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
isStarred,
@ -189,18 +191,26 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<st
.sort((a, b) => a.name.localeCompare(b.name));
}
export function Agents() {
export type AgentsView = "list" | "org";
export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) {
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const location = useLocation();
const { isMobile } = useSidebar();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const pathSegment = location.pathname.split("/").pop() ?? "all";
const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "all";
const [view, setView] = useState<"list" | "org">("org");
const forceListView = isMobile;
const effectiveView: "list" | "org" = forceListView ? "list" : view;
const [view, setView] = useState<AgentsView>(() => streamlinedUiEnabled ? initialView : "org");
const forceListView = !streamlinedUiEnabled && isMobile;
const effectiveView: AgentsView = forceListView ? "list" : view;
useEffect(() => {
setView(streamlinedUiEnabled ? initialView : "org");
}, [initialView, streamlinedUiEnabled]);
const { data: boardAccess } = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
@ -298,12 +308,6 @@ export function Agents() {
return map;
}, [runs]);
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const environmentsById = useMemo(() => {
const map = new Map<string, Environment>();
for (const environment of environments ?? []) map.set(environment.id, environment);
@ -506,7 +510,11 @@ export function Agents() {
};
return (
<div className="space-y-4">
<div className={cn(
effectiveView === "org"
? "flex h-full min-h-0 flex-col gap-4"
: "space-y-4",
)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
<PageTabBar
@ -516,35 +524,32 @@ export function Agents() {
/>
</Tabs>
<div className="flex items-center gap-2">
{/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border" role="group" aria-label="View mode">
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
{!forceListView ? <div className="flex items-center overflow-hidden rounded-md border border-border" role="group" aria-label="Agent view">
<Button
type="button"
size="icon-sm"
variant={effectiveView === "list" ? "secondary" : "ghost"}
className="rounded-none"
onClick={() => setView("list")}
title="List view"
aria-label="List view"
aria-pressed={effectiveView === "list"}
>
<List className="h-3.5 w-3.5" />
</button>
<button
className={cn(
"p-1.5 transition-colors",
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
)}
</Button>
<Button
type="button"
size="icon-sm"
variant={effectiveView === "org" ? "secondary" : "ghost"}
className="rounded-none border-l border-border"
onClick={() => setView("org")}
title="Org chart view"
aria-label="Org chart view"
aria-pressed={effectiveView === "org"}
>
<GitBranch className="h-3.5 w-3.5" />
</button>
</div>
)}
<Network className="h-3.5 w-3.5" />
</Button>
</div> : null}
<Button size="sm" variant="outline" onClick={openNewAgent}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Agent
@ -582,25 +587,7 @@ export function Agents() {
{/* Org chart view */}
{effectiveView === "org" && filteredOrg.length > 0 && (
<div className="py-1">
{filteredOrg.map((node) => (
<OrgTreeNode
key={node.id}
node={node}
depth={0}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
environmentByAgentId={environmentByAgentId}
environmentDataLoading={environmentDataLoading}
showEnvironment={showEnvironmentColumn}
tab={tab}
memberships={membershipsQuery.data}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={setConfigureState}
/>
))}
</div>
<OrgChart embedded orgTree={filteredOrg} agents={agents ?? []} />
)}
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,13 @@
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { CatalogSkill, CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared";
import type { CatalogSkill, CompanySkillDetail, CompanySkillListItem, CompanySkillVersion, FolderListResult } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DiscoveryGrid,
InstallPreviewDialog,
SkillDetailPage,
buildDiscoveryCards,
defaultInstallAgentSelection,
getSkillVersionDiffSelection,
resolveDiscoveryTab,
@ -250,9 +251,7 @@ async function renderDiscoveryGrid(props: Partial<ComponentProps<typeof Discover
await act(async () => {
root?.render(
<DiscoveryGrid
tab="all"
tabCounts={{ all: 0, installed: 0, catalog: 0, bundled: 0 }}
onTabChange={vi.fn()}
tab="installed"
categories={[]}
categoryTotal={0}
activeCategory={null}
@ -269,7 +268,7 @@ async function renderDiscoveryGrid(props: Partial<ComponentProps<typeof Discover
onCreate={vi.fn()}
onImport={vi.fn()}
onImportFromProject={vi.fn()}
onBrowseCatalog={vi.fn()}
onBrowseDiscover={vi.fn()}
onScan={vi.fn()}
scanPending={false}
scanStatus={null}
@ -376,14 +375,66 @@ describe("getSkillVersionDiffSelection", () => {
});
});
describe("DiscoveryGrid Studio entry points", () => {
it("links the header Studio button to Skill Studio", async () => {
const node = await renderDiscoveryGrid();
const studioLink = Array.from(node.querySelectorAll("a")).find((link) =>
link.textContent?.includes("Studio"),
);
describe("DiscoveryGrid IA presentation", () => {
it("makes the search scope explicit for Installed and Discover", async () => {
let node = await renderDiscoveryGrid({ tab: "installed" });
expect(node.querySelector('input[aria-label="Search installed skills"]')).not.toBeNull();
expect(studioLink?.getAttribute("href")).toBe("/skills/studio");
root?.unmount();
container?.remove();
root = null;
container = null;
node = await renderDiscoveryGrid({ tab: "discover" });
expect(node.querySelector('input[aria-label="Search discoverable skills"]')).not.toBeNull();
expect(node.textContent).not.toContain("Catalog");
});
it("distinguishes installation from agent enablement on cards", async () => {
const installedCard = {
key: "installed",
skillId: "skill-installed",
catalogRef: null,
name: "Installed Skill",
slug: "installed",
author: "Paperclip",
version: null,
tagline: null,
description: null,
categories: [],
iconUrl: null,
color: null,
starCount: 0,
agentCount: 0,
forkCount: 0,
installed: true,
required: false,
forkedFrom: false,
updatedAt: 0,
sourceBadge: "local" as const,
sourceLabel: "Local workspace",
};
const availableCard = {
...installedCard,
key: "available",
skillId: null,
catalogRef: "catalog-available",
name: "Available Skill",
slug: "available",
installed: false,
sourceBadge: "catalog" as const,
sourceLabel: "Paperclip catalog",
};
const node = await renderDiscoveryGrid({
tab: "discover",
cards: [installedCard, availableCard],
totalCount: 2,
});
expect(node.textContent).toContain("Not enabled for any agents");
expect(node.textContent).toContain("Available to install");
expect(node.textContent).toContain("Local workspace");
expect(node.textContent).toContain("Paperclip catalog");
});
it("uses the create callback from the New menu and empty state", async () => {
@ -396,6 +447,17 @@ describe("DiscoveryGrid Studio entry points", () => {
expect(onCreate).toHaveBeenCalledTimes(2);
});
it("uses one Discover action instead of Catalog or Bundled navigation", async () => {
const onBrowseDiscover = vi.fn();
const node = await renderDiscoveryGrid({ onBrowseDiscover });
await click(buttonsNamed(node, "Discover skills")[0] as HTMLButtonElement);
expect(onBrowseDiscover).toHaveBeenCalledOnce();
expect(node.textContent).not.toContain("Browse catalog");
expect(node.textContent).not.toContain("Bundled tab");
});
it("keeps folder creation in the compact rail control", async () => {
const props = projectFolderGridProps();
const node = await renderDiscoveryGrid(props);
@ -410,6 +472,29 @@ describe("DiscoveryGrid Studio entry points", () => {
expect(props.onCreateFolder).not.toHaveBeenCalled();
});
it("removes category and folder browse rails while keeping search available", async () => {
const node = await renderDiscoveryGrid({
...projectFolderGridProps(),
categories: [{ slug: "design", count: 1 }],
categoryTotal: 1,
showBrowseRails: false,
});
expect(node.querySelector('nav[aria-label="Skill folders"]')).toBeNull();
expect(node.querySelector("aside")).toBeNull();
expect(node.textContent).not.toContain("Browse by category");
expect(node.querySelector('input[aria-label="Search installed skills"]')).not.toBeNull();
});
it("omits bulk selection when its entry point is not supplied", async () => {
const node = await renderDiscoveryGrid({
...projectFolderGridProps(),
onToggleSelectMode: undefined,
});
expect(buttonsNamed(node, "Select")).toHaveLength(0);
});
it("keeps folder creation available when no folder rail exists", async () => {
const onCreateFolder = vi.fn();
const node = await renderDiscoveryGrid({
@ -548,18 +633,77 @@ describe("DiscoveryGrid Studio entry points", () => {
describe("skills discovery tab routing", () => {
it("opens the folder-first installed view when the URL has no tab", () => {
expect(resolveDiscoveryTab(null)).toBe("installed");
expect(resolveDiscoveryTab("all")).toBe("all");
expect(resolveDiscoveryTab("all")).toBe("discover");
expect(resolveDiscoveryTab("catalog")).toBe("discover");
expect(resolveDiscoveryTab("bundled")).toBe("discover");
});
it("keeps All explicit and makes Installed the canonical default URL", () => {
const allParams = withDiscoveryTab(new URLSearchParams("folder=my&category=writing"), "all");
expect(allParams.toString()).toBe("tab=all");
it("uses Discover as the only explicit discovery URL and Installed as the default", () => {
const discoverParams = withDiscoveryTab(new URLSearchParams("folder=my&category=writing"), "discover");
expect(discoverParams.toString()).toBe("tab=discover");
const installedParams = withDiscoveryTab(new URLSearchParams("tab=all&folder=my"), "installed");
expect(installedParams.toString()).toBe("folder=my");
});
});
describe("skills discovery card reconciliation", () => {
it("renders one installed card when installed and catalog data share a key", () => {
const installed = {
id: "installed-new",
key: "PaperclipAI/Review",
name: "Review",
slug: "review",
updatedAt: new Date("2026-08-30T00:00:00Z"),
folderId: null,
authorName: "Paperclip",
packageVersion: "2.0.0",
sourceRef: null,
tagline: null,
description: "Installed copy",
categories: [],
iconUrl: null,
color: null,
starCount: 0,
attachedAgentCount: 2,
forkCount: 0,
catalogKind: "optional",
forkedFromSkillId: null,
sourceBadge: "catalog",
sourceLabel: "Paperclip",
} as unknown as CompanySkillListItem;
const olderDuplicate = {
...installed,
id: "installed-old",
key: "paperclipai/review",
updatedAt: new Date("2026-08-01T00:00:00Z"),
} as CompanySkillListItem;
const catalog = {
id: "catalog-review",
key: "paperclipai/review",
name: "Review",
slug: "review",
kind: "optional",
category: "quality",
description: "Catalog copy",
packageName: "Paperclip",
packageVersion: "2.0.0",
tags: [],
} as unknown as CatalogSkill;
const cards = buildDiscoveryCards([olderDuplicate, installed], [catalog, { ...catalog, id: "catalog-duplicate" }]);
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
skillId: "installed-new",
catalogRef: "catalog-review",
installed: true,
agentCount: 2,
sourceKind: "optional",
});
});
});
describe("skill detail breadcrumbs", () => {
it("links each folder ancestor back to the installed folder view", () => {
const folders: FolderListResult = {

View File

@ -38,6 +38,7 @@ import { Identity } from "../components/Identity";
import { AgentIcon } from "../components/AgentIconPicker";
import { AgentMultiSelect } from "../components/AgentMultiSelect";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import {
SkillPolicyDenialNotice,
useSkillPolicyDenial,
@ -75,6 +76,10 @@ import {
resolveSkillRouteToken,
type CompanySkillRouteSubject,
} from "../lib/company-skill-routes";
import {
resolveSkillsDiscoveryView,
withSkillsDiscoveryView,
} from "./skills/skills-navigation";
import {
SKILL_CREATE_ACCENTS,
buildBlankSkillDraft,
@ -126,6 +131,7 @@ import {
ChevronLeft,
ChevronRight,
Code2,
Compass,
Download,
Eye,
Filter,
@ -552,23 +558,14 @@ function formatBytes(bytes: number) {
// Skills Store discovery grid (PAP-10879)
// ---------------------------------------------------------------------------
export type DiscoveryTab = "all" | "installed" | "catalog" | "bundled";
const DISCOVERY_TABS: DiscoveryTab[] = ["all", "installed", "catalog", "bundled"];
export type DiscoveryTab = "installed" | "discover";
export function resolveDiscoveryTab(tabParam: string | null): DiscoveryTab {
return DISCOVERY_TABS.includes(tabParam as DiscoveryTab)
? (tabParam as DiscoveryTab)
: "installed";
return resolveSkillsDiscoveryView(tabParam);
}
export function withDiscoveryTab(current: URLSearchParams, tab: DiscoveryTab): URLSearchParams {
const params = new URLSearchParams(current);
if (tab === "installed") params.delete("tab");
else params.set("tab", tab);
params.delete("category");
if (tab !== "installed") params.delete("folder");
return params;
return withSkillsDiscoveryView(current, tab);
}
export function skillDetailBreadcrumbs(
@ -623,6 +620,7 @@ export type DiscoveryCard = {
updatedAt: number;
sourceBadge?: CompanySkillSourceBadge | null;
sourceLabel?: string | null;
sourceKind?: "bundled" | "optional" | null;
};
export { SkillCardIcon } from "../components/SkillCardIcon";
@ -662,17 +660,36 @@ function skillSettingsToastBody(skill: Pick<CompanySkillDetail, "categories" | "
// Merge installed company skills and the install catalog into one card model.
// Installed skills win on dedup (they carry the richer social-proof metadata);
// catalog-only skills fill in the rest of the discoverable surface.
function buildDiscoveryCards(
function discoveryCardIdentity(key: string): string {
return key.trim().toLowerCase();
}
export function buildDiscoveryCards(
installed: CompanySkillListItem[],
catalog: CatalogSkill[],
): DiscoveryCard[] {
const catalogByKey = new Map(catalog.map((entry) => [entry.key, entry]));
const installedByKey = new Map<string, CompanySkillListItem>();
for (const skill of installed) {
const identity = discoveryCardIdentity(skill.key);
const existing = installedByKey.get(identity);
if (!existing || new Date(skill.updatedAt).getTime() > new Date(existing.updatedAt).getTime()) {
installedByKey.set(identity, skill);
}
}
const catalogByKey = new Map<string, CatalogSkill>();
for (const entry of catalog) {
const identity = discoveryCardIdentity(entry.key);
if (!catalogByKey.has(identity)) catalogByKey.set(identity, entry);
}
const cards: DiscoveryCard[] = [];
const installedKeys = new Set<string>();
for (const skill of installed) {
installedKeys.add(skill.key);
const catalogMatch = catalogByKey.get(skill.key) ?? null;
for (const skill of installedByKey.values()) {
const identity = discoveryCardIdentity(skill.key);
installedKeys.add(identity);
const catalogMatch = catalogByKey.get(identity) ?? null;
const required = skill.catalogKind === "bundled" || catalogMatch?.kind === "bundled";
cards.push({
key: skill.key,
@ -697,11 +714,12 @@ function buildDiscoveryCards(
updatedAt: new Date(skill.updatedAt).getTime() || 0,
sourceBadge: skill.sourceBadge,
sourceLabel: skill.sourceLabel,
sourceKind: skill.catalogKind ?? catalogMatch?.kind ?? null,
});
}
for (const entry of catalog) {
if (installedKeys.has(entry.key)) continue;
for (const [identity, entry] of catalogByKey) {
if (installedKeys.has(identity)) continue;
const required = entry.kind === "bundled";
cards.push({
key: entry.key,
@ -726,6 +744,7 @@ function buildDiscoveryCards(
updatedAt: 0,
sourceBadge: "catalog",
sourceLabel: entry.packageName ?? "Catalog",
sourceKind: entry.kind,
});
}
@ -733,17 +752,7 @@ function buildDiscoveryCards(
}
function cardsForTab(cards: DiscoveryCard[], tab: DiscoveryTab): DiscoveryCard[] {
switch (tab) {
case "installed":
return cards.filter((card) => card.installed);
case "catalog":
return cards.filter((card) => card.catalogRef != null);
case "bundled":
return cards.filter((card) => card.required);
case "all":
default:
return cards;
}
return tab === "installed" ? cards.filter((card) => card.installed) : cards;
}
function sortDiscoveryCards(cards: DiscoveryCard[], sort: DiscoverySort, demoteRequired: boolean): DiscoveryCard[] {
@ -823,6 +832,8 @@ function SkillCard({
onCreateFolderAndMove?: (card: DiscoveryCard) => void;
onOpenMove?: (card: DiscoveryCard) => void;
}) {
const source = sourceMeta(card.sourceBadge ?? "catalog", card.sourceLabel ?? null);
const SourceIcon = source.icon;
const badgeFolder = showFolderBadge && card.installed
? (card.folderId ? folders?.find((folder) => folder.id === card.folderId) ?? null : null)
: undefined;
@ -873,16 +884,6 @@ function SkillCard({
</div>
) : null}
</div>
{/* Where the skill came from (PAP-10907 E); native title gives a hover hint. */}
{(() => {
const meta = sourceMeta(card.sourceBadge ?? "catalog", card.sourceLabel ?? null);
const SourceIcon = meta.icon;
return (
<span className="shrink-0 text-muted-foreground" title={`From ${meta.label}`} aria-label={`From ${meta.label}`}>
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
);
})()}
{canMove && folders && onMove && onCreateFolderAndMove ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -935,9 +936,15 @@ function SkillCard({
</p>
<div className="mt-auto pt-3">
{/* Stats: installed agents · stars · forks — stars/forks only when > 0. */}
{/* Installation and agent enablement are separate states. */}
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{card.agentCount} {card.agentCount === 1 ? "agent" : "agents"}</span>
<span>
{card.installed
? card.agentCount > 0
? `Enabled for ${card.agentCount} ${card.agentCount === 1 ? "agent" : "agents"}`
: "Not enabled for any agents"
: "Available to install"}
</span>
{card.starCount > 0 ? (
<>
<span aria-hidden="true">·</span>
@ -953,10 +960,14 @@ function SkillCard({
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
{card.installed ? (
<Badge variant="outline" className="border-emerald-500/30 bg-emerald-500/10 text-(length:--text-nano) text-emerald-700 dark:text-emerald-300">
<Badge variant="secondary" className="text-(length:--text-nano)">
Installed
</Badge>
) : null}
<Badge variant="outline" className="max-w-full text-(length:--text-nano) text-muted-foreground">
<SourceIcon className="h-3 w-3" aria-hidden="true" />
<span className="truncate">{source.label}</span>
</Badge>
{card.categories.slice(0, 2).map((category) => (
<SkillCategoryChip key={category} label={category} />
))}
@ -965,6 +976,10 @@ function SkillCard({
<Lock className="h-3 w-3" aria-hidden="true" />
Bundled
</Badge>
) : card.sourceKind === "optional" ? (
<Badge variant="outline" className="ml-auto text-(length:--text-nano) text-muted-foreground">
Optional
</Badge>
) : null}
</div>
</div>
@ -1018,8 +1033,6 @@ function CategoryNav({
export function DiscoveryGrid({
tab,
tabCounts,
onTabChange,
categories,
categoryTotal,
activeCategory,
@ -1036,7 +1049,7 @@ export function DiscoveryGrid({
onCreate,
onImport,
onImportFromProject,
onBrowseCatalog,
onBrowseDiscover,
onScan,
scanPending,
scanStatus,
@ -1063,10 +1076,9 @@ export function DiscoveryGrid({
onEnsureMyFolder,
onOpenMoveCard,
folderNudgeStorageKey,
showBrowseRails = true,
}: {
tab: DiscoveryTab;
tabCounts: Record<DiscoveryTab, number>;
onTabChange: (tab: DiscoveryTab) => void;
categories: DiscoveryCategory[];
categoryTotal: number;
activeCategory: string | null;
@ -1083,7 +1095,7 @@ export function DiscoveryGrid({
onCreate: () => void;
onImport: () => void;
onImportFromProject: () => void;
onBrowseCatalog: () => void;
onBrowseDiscover: () => void;
onScan: (projectId?: string) => void;
scanPending: boolean;
scanStatus: string | null;
@ -1114,22 +1126,43 @@ export function DiscoveryGrid({
onOpenMoveCard?: (card: DiscoveryCard) => void;
/** When set and no folders exist yet, show the dismissible all-unfiled nudge (ux-spec §6.3). */
folderNudgeStorageKey?: string;
/** Category/folder navigation stays available in production, but the Streamlined UI relies on search and scrolling. */
showBrowseRails?: boolean;
}) {
const installedView = tab === "installed";
const viewTitle = installedView ? "Installed skills" : "Discover skills";
const viewDescription = installedView
? "Skills available to this organization."
: "Browse skills from every available source.";
const searchLabel = installedView ? "Search installed skills" : "Search discoverable skills";
// Source filter (github / skills.sh / local / …) lives in the grid so it
// narrows whatever the parent already filtered by tab/category/search (PAP-10907 E).
const [sourceBadgeFilter, setSourceBadgeFilter] = useState<string>("all");
const availableSources = useMemo(() => {
const set = new Set<string>();
for (const card of cards) if (card.sourceBadge) set.add(card.sourceBadge);
return Array.from(set).sort();
const facets = new Map<string, string>();
for (const card of cards) {
if (card.sourceKind) {
facets.set(`kind:${card.sourceKind}`, card.sourceKind === "bundled" ? "Bundled" : "Optional");
}
if (card.sourceBadge) {
facets.set(`badge:${card.sourceBadge}`, sourceMeta(card.sourceBadge, null).label);
}
}
return Array.from(facets, ([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label));
}, [cards]);
useEffect(() => {
if (sourceBadgeFilter !== "all" && !availableSources.includes(sourceBadgeFilter)) {
if (sourceBadgeFilter !== "all" && !availableSources.some((source) => source.value === sourceBadgeFilter)) {
setSourceBadgeFilter("all");
}
}, [availableSources, sourceBadgeFilter]);
const sourceFilteredCards = useMemo(
() => (sourceBadgeFilter === "all" ? cards : cards.filter((card) => card.sourceBadge === sourceBadgeFilter)),
() => sourceBadgeFilter === "all"
? cards
: cards.filter((card) => {
const [facet, value] = sourceBadgeFilter.split(":", 2);
return facet === "kind" ? card.sourceKind === value : card.sourceBadge === value;
}),
[cards, sourceBadgeFilter],
);
const sourceFilterActive = sourceBadgeFilter !== "all";
@ -1139,7 +1172,7 @@ export function DiscoveryGrid({
// The nested folder tree owns the left rail whenever folders (reserved roots
// or user folders) exist for the installed view.
const showFolderRail = Boolean(
folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady,
showBrowseRails && folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady,
);
const activeProjectFolder = useMemo(() => {
if (!folderResult || folderSelection === "all" || folderSelection === "unfiled") return null;
@ -1172,35 +1205,42 @@ export function DiscoveryGrid({
/>
</div>
) : null}
{/* Secondary category sidebar the main app nav collapses to a rail while
this is present (handled in Layout). */}
<aside className={cn("hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex", showFolderRail && "md:hidden")}>
<div className="border-b border-border px-4 py-4">
<h2 className="text-sm font-semibold text-foreground">Skills Store</h2>
<p className="text-xs text-muted-foreground">Discover, install, fork, share</p>
</div>
<div className="px-4 pb-1 pt-3 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Categories
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-4">
<CategoryNav
categories={categories}
total={categoryTotal}
active={activeCategory}
onSelect={onCategoryChange}
/>
</div>
</aside>
{showBrowseRails ? (
<aside className={cn("hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex", showFolderRail && "md:hidden")}>
<div className="border-b border-border px-4 py-4">
<h2 className="text-sm font-semibold text-foreground">Browse by category</h2>
<p className="text-xs text-muted-foreground">
Filter {installedView ? "installed" : "discoverable"} skills.
</p>
</div>
<div className="px-4 pb-1 pt-3 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Categories
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-4">
<CategoryNav
categories={categories}
total={categoryTotal}
active={activeCategory}
onSelect={onCategoryChange}
/>
</div>
</aside>
) : null}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Search + sort + actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div className="w-full">
<h1 className="text-lg font-semibold text-foreground">{viewTitle}</h1>
<p className="text-xs text-muted-foreground">{viewDescription}</p>
</div>
<div className="flex h-9 min-w-(--sz-12rem) flex-1 items-center gap-2 rounded-md border border-border px-2.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search skills, authors, categories…"
aria-label={searchLabel}
placeholder={`${searchLabel}`}
className="h-full w-full bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-sm"
/>
</div>
@ -1227,8 +1267,10 @@ export function DiscoveryGrid({
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-muted-foreground">Source</span>
<span className="ml-1.5 capitalize">
{sourceBadgeFilter === "all" ? "All" : sourceMeta(sourceBadgeFilter as CompanySkillSourceBadge, null).label}
<span className="ml-1.5">
{sourceBadgeFilter === "all"
? "All"
: availableSources.find((source) => source.value === sourceBadgeFilter)?.label ?? "All"}
</span>
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
@ -1236,9 +1278,9 @@ export function DiscoveryGrid({
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={sourceBadgeFilter} onValueChange={setSourceBadgeFilter}>
<DropdownMenuRadioItem value="all">All sources</DropdownMenuRadioItem>
{availableSources.map((badge) => (
<DropdownMenuRadioItem key={badge} value={badge}>
{sourceMeta(badge as CompanySkillSourceBadge, null).label}
{availableSources.map((source) => (
<DropdownMenuRadioItem key={source.value} value={source.value}>
{source.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -1255,12 +1297,6 @@ export function DiscoveryGrid({
>
<RefreshCw className={cn("h-4 w-4", scanPending && "animate-spin")} />
</Button>
<Button asChild variant="outline" size="sm">
<Link to="/skills/studio">
<FlaskConical className="h-3.5 w-3.5" />
Studio
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default">
@ -1274,9 +1310,9 @@ export function DiscoveryGrid({
<Pencil className="mr-2 h-4 w-4" />
Create new skill
</DropdownMenuItem>
<DropdownMenuItem onSelect={onBrowseCatalog}>
<Boxes className="mr-2 h-4 w-4" />
Browse catalog
<DropdownMenuItem onSelect={onBrowseDiscover}>
<Compass className="mr-2 h-4 w-4" />
Discover skills
</DropdownMenuItem>
<DropdownMenuItem onSelect={onImport}>
<Globe className="mr-2 h-4 w-4" />
@ -1288,7 +1324,7 @@ export function DiscoveryGrid({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{folderResult && onFolderSelect ? (
{showBrowseRails && folderResult && onFolderSelect ? (
<div className="w-full md:hidden">
<FolderChip
result={folderResult}
@ -1312,7 +1348,7 @@ export function DiscoveryGrid({
</div>
{/* Mobile category selector (sidebar is hidden below md) */}
{categories.length > 0 ? (
{showBrowseRails && categories.length > 0 ? (
<div className="border-b border-border px-4 py-2 md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1338,30 +1374,6 @@ export function DiscoveryGrid({
</div>
) : null}
{/* Tab strip — Bundled/required lives at the end */}
<div className="border-b border-border px-4">
<Tabs value={tab} onValueChange={(value) => onTabChange(value as DiscoveryTab)}>
<TabsList variant="line" className="p-0">
<TabsTrigger value="all" className="px-3">
<span>All</span>
<span className="ml-1.5 text-(length:--text-micro) text-muted-foreground">{tabCounts.all}</span>
</TabsTrigger>
<TabsTrigger value="installed" className="px-3">
<span>Installed</span>
<span className="ml-1.5 text-(length:--text-micro) text-muted-foreground">{tabCounts.installed}</span>
</TabsTrigger>
<TabsTrigger value="catalog" className="px-3">
<span>Catalog</span>
<span className="ml-1.5 text-(length:--text-micro) text-muted-foreground">{tabCounts.catalog}</span>
</TabsTrigger>
<TabsTrigger value="bundled" className="px-3">
<span>Bundled</span>
<span className="ml-1.5 text-(length:--text-micro) text-muted-foreground">{tabCounts.bundled}</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Grid body */}
<div className="min-h-0 flex-1 overflow-auto p-4">
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
@ -1412,17 +1424,21 @@ export function DiscoveryGrid({
icon={LayoutGrid}
message={
totalCount === 0
? "No skills yet. Create one or install from the catalog."
? installedView
? "No installed skills yet. Discover a skill or create one."
: "No skills are available to discover yet."
: search || activeCategory || sourceFilterActive
? "No skills match your filters."
: "No skills in this tab yet."
: "No skills in this view yet."
}
/>
{totalCount === 0 ? (
<div className="mt-3 flex flex-col items-center gap-2">
<Button size="sm" onClick={onBrowseCatalog}>
<Boxes className="mr-1.5 h-3.5 w-3.5" /> Browse catalog
</Button>
{installedView ? (
<Button size="sm" onClick={onBrowseDiscover}>
<Compass className="mr-1.5 h-3.5 w-3.5" /> Discover skills
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={onCreate}>
Create a skill
</Button>
@ -4005,6 +4021,7 @@ export function CompanySkills() {
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const adapterCaps = useAdapterCapabilities();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const policyDenial = useSkillPolicyDenial();
// Route a failed skill mutation to the persistent policy banner when it is an
// explicit-policy (State B) or platform-safety (State C) denial; otherwise keep
@ -4076,7 +4093,17 @@ export function CompanySkills() {
: "all";
const selectedCatalogRef = searchParams.get("catalog");
const tabParam = searchParams.get("tab");
const discoveryTab = resolveDiscoveryTab(tabParam);
const discoveryTab = resolveDiscoveryTab(tabParam ?? (viewParam === "catalog" ? "catalog" : null));
const legacyDiscoveryTab = (["all", "installed", "catalog", "bundled"] as const).includes(
tabParam as "all" | "installed" | "catalog" | "bundled",
)
? (tabParam as "all" | "installed" | "catalog" | "bundled")
: "installed";
const effectiveDiscoveryTab: DiscoveryTab = streamlinedUiEnabled
? discoveryTab
: legacyDiscoveryTab === "installed"
? "installed"
: "discover";
const detailTab: SkillDetailTab = (["overview", "files", "versions", "agents"] as SkillDetailTab[]).includes(tabParam as SkillDetailTab)
? (tabParam as SkillDetailTab)
: parsedRoute.hasExplicitFilePath || selectedPath !== "SKILL.md"
@ -4089,11 +4116,25 @@ export function CompanySkills() {
// selected; selecting either drops into the existing master/detail surfaces.
const isDiscovery = !isStudioNew && !routeSkillToken && !selectedCatalogRef;
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
const browseRailsEnabled = !streamlinedUiEnabled;
const visibleDiscoveryCategory = browseRailsEnabled ? discoveryCategory : null;
const visibleFolderSelection: FolderSelection = browseRailsEnabled ? folderSelection : "all";
function setDiscoveryTab(tab: DiscoveryTab) {
setSearchParams((current) => withDiscoveryTab(current, tab));
}
function setLegacyDiscoveryTab(tab: "all" | "installed" | "catalog" | "bundled") {
setSearchParams((current) => {
const params = new URLSearchParams(current);
if (tab === "installed") params.delete("tab");
else params.set("tab", tab);
params.delete("category");
if (tab !== "installed") params.delete("folder");
return params;
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
@ -4155,20 +4196,36 @@ export function CompanySkills() {
setCreateError(null);
}, [isStudioNew, studioForkFromId]);
// The old split catalog view no longer exists — catalog/bundled skills now open
// as a regular full page keyed by `?catalog=<ref>`. Strip the legacy `view`
// param so stale `?view=catalog` deep links land on the new surface (PAP-10907).
// Canonicalize the old split-view and multi-tab URLs into the single Discover
// destination while keeping every stale deep link useful.
useEffect(() => {
if (!searchParams.has("view")) return;
if (!streamlinedUiEnabled) return;
const legacyTab = searchParams.get("tab");
const hasLegacyDiscoveryTab = isDiscovery && ["all", "catalog", "bundled"].includes(legacyTab ?? "");
const hasRetiredBrowseFilter = isDiscovery && (searchParams.has("category") || searchParams.has("folder"));
if (!searchParams.has("view") && !hasLegacyDiscoveryTab && !hasRetiredBrowseFilter) return;
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (hasLegacyDiscoveryTab || (next.get("view") === "catalog" && !next.has("tab"))) {
next.set("tab", "discover");
}
next.delete("view");
if (isDiscovery) {
next.delete("category");
next.delete("folder");
}
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
}, [isDiscovery, searchParams, setSearchParams, streamlinedUiEnabled]);
useEffect(() => {
if (!streamlinedUiEnabled) return;
setSelectMode(false);
setSelectedSkillIds([]);
}, [streamlinedUiEnabled]);
const skillsQuery = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
@ -4178,7 +4235,7 @@ export function CompanySkills() {
const skillFoldersQuery = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId ?? "", "skill"),
queryFn: () => foldersApi.list(selectedCompanyId!, "skill"),
enabled: Boolean(selectedCompanyId && ((isDiscovery && discoveryTab === "installed") || routeSkillToken)),
enabled: Boolean(selectedCompanyId && ((isDiscovery && effectiveDiscoveryTab === "installed") || routeSkillToken)),
});
const installedSkills = skillsQuery.data ?? [];
@ -4227,15 +4284,15 @@ export function CompanySkills() {
// The writable folder to seed a new skill into when creating from the browser.
const defaultNewSkillFolderId = useMemo(() => {
if (folderSelection === "all" || folderSelection === "unfiled") return null;
if (visibleFolderSelection === "all" || visibleFolderSelection === "unfiled") return null;
const model = treeFromResult(skillFoldersQuery.data);
const folder = model.byId.get(folderSelection);
const folder = model.byId.get(visibleFolderSelection);
if (!folder) return null;
// Never seed into read-only reserved subtrees (Bundled / Projects).
if (folder.path === "bundled" || folder.path.startsWith("bundled/")) return null;
if (folder.path === "projects" || folder.path.startsWith("projects/")) return null;
return folder.id;
}, [folderSelection, skillFoldersQuery.data]);
}, [skillFoldersQuery.data, visibleFolderSelection]);
const updateStatusQuery = useQuery({
queryKey: queryKeys.companySkills.updateStatus(selectedCompanyId ?? "", selectedSkillId ?? ""),
@ -4569,15 +4626,15 @@ export function CompanySkills() {
() => buildDiscoveryCards(installedSkills, catalogListQuery.data ?? []),
[installedSkills, catalogListQuery.data],
);
const discoveryTabCounts = useMemo(() => ({
all: discoveryCards.length,
installed: discoveryCards.filter((card) => card.installed).length,
catalog: discoveryCards.filter((card) => card.catalogRef != null).length,
bundled: discoveryCards.filter((card) => card.required).length,
}), [discoveryCards]);
const discoveryTabCards = useMemo(
() => cardsForTab(discoveryCards, discoveryTab),
[discoveryCards, discoveryTab],
() => {
if (streamlinedUiEnabled) return cardsForTab(discoveryCards, discoveryTab);
if (legacyDiscoveryTab === "installed") return discoveryCards.filter((card) => card.installed);
if (legacyDiscoveryTab === "catalog") return discoveryCards.filter((card) => card.catalogRef != null);
if (legacyDiscoveryTab === "bundled") return discoveryCards.filter((card) => card.required);
return discoveryCards;
},
[discoveryCards, discoveryTab, legacyDiscoveryTab, streamlinedUiEnabled],
);
const discoveryCategoryCounts = useMemo<DiscoveryCategory[]>(() => {
const counts = new Map<string, number>();
@ -4594,24 +4651,24 @@ export function CompanySkills() {
// Selecting a folder shows its whole subtree (folder + descendants), matching
// the folder-browser model. `null` means no subtree constraint (All/Unfiled).
const folderSubtreeIds = useMemo(() => {
if (folderSelection === "all" || folderSelection === "unfiled") return null;
if (visibleFolderSelection === "all" || visibleFolderSelection === "unfiled") return null;
const model = treeFromResult(skillFoldersQuery.data);
if (!model.byId.has(folderSelection)) return null;
return subtreeFolderIds(model, folderSelection);
}, [folderSelection, skillFoldersQuery.data]);
if (!model.byId.has(visibleFolderSelection)) return null;
return subtreeFolderIds(model, visibleFolderSelection);
}, [skillFoldersQuery.data, visibleFolderSelection]);
const visibleDiscoveryCards = useMemo(() => {
const filtered = discoveryTabCards.filter((card) => {
if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false;
if (visibleDiscoveryCategory && !card.categories.includes(visibleDiscoveryCategory)) return false;
// Search spans all folders (user story 5): the folder filter only
// narrows when the user is browsing, never when searching.
if (discoveryTab === "installed" && !discoverySearchActive) {
if (folderSelection === "unfiled" && card.folderId) return false;
if (effectiveDiscoveryTab === "installed" && !discoverySearchActive) {
if (visibleFolderSelection === "unfiled" && card.folderId) return false;
if (folderSubtreeIds && (!card.folderId || !folderSubtreeIds.has(card.folderId))) return false;
}
return discoveryMatchesSearch(card, discoverySearch.trim());
});
return sortDiscoveryCards(filtered, discoverySort, discoveryTab !== "bundled");
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySearchActive, discoverySort, discoveryTab, folderSelection, folderSubtreeIds]);
return sortDiscoveryCards(filtered, discoverySort, effectiveDiscoveryTab === "discover");
}, [discoverySearch, discoverySearchActive, discoverySort, discoveryTabCards, effectiveDiscoveryTab, folderSubtreeIds, visibleDiscoveryCategory, visibleFolderSelection]);
const selectedCatalogSkill = catalogDetailQuery.data
?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef)
@ -4858,9 +4915,9 @@ export function CompanySkills() {
async function openNewSkill() {
const model = treeFromResult(skillFoldersQuery.data);
const selectedFolder = folderSelection === "all" || folderSelection === "unfiled"
const selectedFolder = visibleFolderSelection === "all" || visibleFolderSelection === "unfiled"
? null
: model.byId.get(folderSelection) ?? null;
: model.byId.get(visibleFolderSelection) ?? null;
if (selectedFolder?.systemKey === "my") {
try {
const personalFolder = await ensureMyFolder.mutateAsync();
@ -5072,13 +5129,14 @@ export function CompanySkills() {
});
const skillFolderResult = skillFoldersQuery.data ?? null;
const showInstalledFolders = isDiscovery && discoveryTab === "installed";
const showInstalledFolders = isDiscovery && effectiveDiscoveryTab === "installed";
const showInstalledBulkSelection = showInstalledFolders && !streamlinedUiEnabled;
// Rail counts reflect the current category/search scope, never the folder
// filter itself (ux-spec §5.3).
const railSkillFolderResult = useMemo(() => {
if (!skillFolderResult || discoveryTab !== "installed") return skillFolderResult;
if (!skillFolderResult || effectiveDiscoveryTab !== "installed") return skillFolderResult;
const scoped = discoveryTabCards.filter((card) => {
if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false;
if (visibleDiscoveryCategory && !card.categories.includes(visibleDiscoveryCategory)) return false;
return discoveryMatchesSearch(card, discoverySearch.trim());
});
const direct = new Map<string, number>();
@ -5100,7 +5158,7 @@ export function CompanySkills() {
return { ...folder, itemCount };
}),
};
}, [skillFolderResult, discoveryTab, discoveryTabCards, discoveryCategory, discoverySearch]);
}, [discoverySearch, discoveryTabCards, effectiveDiscoveryTab, skillFolderResult, visibleDiscoveryCategory]);
const activeSkillFolderDisplayPath = useMemo(
() => skillFolderDisplayPath(treeFromResult(skillFolderResult), activeDetail?.folderId),
[skillFolderResult, activeDetail?.folderId],
@ -5410,13 +5468,24 @@ export function CompanySkills() {
</div>
</div>
) : isDiscovery ? (
<>
{!streamlinedUiEnabled ? (
<div className="px-4 pt-4">
<Tabs value={legacyDiscoveryTab} onValueChange={(value) => setLegacyDiscoveryTab(value as "all" | "installed" | "catalog" | "bundled")}>
<TabsList variant="line" aria-label="Skills view">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="installed">Installed</TabsTrigger>
<TabsTrigger value="catalog">Catalog</TabsTrigger>
<TabsTrigger value="bundled">Bundled</TabsTrigger>
</TabsList>
</Tabs>
</div>
) : null}
<DiscoveryGrid
tab={discoveryTab}
tabCounts={discoveryTabCounts}
onTabChange={setDiscoveryTab}
tab={effectiveDiscoveryTab}
categories={discoveryCategoryCounts}
categoryTotal={discoveryTabCards.length}
activeCategory={discoveryCategory}
activeCategory={visibleDiscoveryCategory}
onCategoryChange={setDiscoveryCategory}
search={discoverySearch}
onSearchChange={setDiscoverySearch}
@ -5426,19 +5495,19 @@ export function CompanySkills() {
onOpenCard={openDiscoveryCard}
loading={skillsQuery.isLoading || catalogListQuery.isLoading}
error={skillsQuery.error?.message ?? catalogListQuery.error?.message ?? null}
totalCount={discoveryCards.length}
totalCount={discoveryTabCards.length}
onCreate={() => void openNewSkill()}
onImport={() => setImportDialogOpen(true)}
onImportFromProject={() => setImportFromProjectOpen(true)}
onBrowseCatalog={() => setDiscoveryTab("catalog")}
onBrowseDiscover={() => streamlinedUiEnabled ? setDiscoveryTab("discover") : setLegacyDiscoveryTab("catalog")}
onScan={(projectId) => scanProjects.mutate(projectId)}
scanPending={scanProjects.isPending}
scanStatus={scanStatusMessage}
folderResult={showInstalledFolders ? railSkillFolderResult : null}
folderSelection={folderSelection}
folderSelection={visibleFolderSelection}
foldersLoading={skillFoldersQuery.isLoading}
selectMode={showInstalledFolders && selectMode}
selectedSkillIds={selectedSkillIds}
selectMode={showInstalledBulkSelection && selectMode}
selectedSkillIds={showInstalledBulkSelection ? selectedSkillIds : []}
onFolderSelect={showInstalledFolders ? setFolderSelection : undefined}
onOpenMobileFolders={showInstalledFolders ? () => setMobileFoldersOpen(true) : undefined}
onCreateFolder={showInstalledFolders ? () => openCreateFolder() : undefined}
@ -5460,11 +5529,11 @@ export function CompanySkills() {
} : undefined}
onMoveFolder={showInstalledFolders ? (folder, destination) => void moveFolderBetweenScopes(folder, destination) : undefined}
onDeleteFolder={showInstalledFolders ? setDeleteFolderTarget : undefined}
onToggleSelectMode={showInstalledFolders ? () => {
onToggleSelectMode={showInstalledBulkSelection ? () => {
setSelectMode((current) => !current);
if (selectMode) setSelectedSkillIds([]);
} : undefined}
onSelectCard={showInstalledFolders ? (card, selected) => {
onSelectCard={showInstalledBulkSelection ? (card, selected) => {
if (!card.skillId) return;
setSelectedSkillIds((current) =>
selected
@ -5492,11 +5561,13 @@ export function CompanySkills() {
onCreateFolderAndMoveCard={showInstalledFolders ? (card) => {
if (card.skillId) openCreateFolder([card.skillId]);
} : undefined}
onMoveSelected={showInstalledFolders ? (folderId) => void moveSelectedSkills(folderId) : undefined}
onCreateFolderAndMoveSelected={showInstalledFolders ? () => openCreateFolder(selectedSkillIds) : undefined}
onClearSelected={showInstalledFolders ? () => setSelectedSkillIds([]) : undefined}
onMoveSelected={showInstalledBulkSelection ? (folderId) => void moveSelectedSkills(folderId) : undefined}
onCreateFolderAndMoveSelected={showInstalledBulkSelection ? () => openCreateFolder(selectedSkillIds) : undefined}
onClearSelected={showInstalledBulkSelection ? () => setSelectedSkillIds([]) : undefined}
folderNudgeStorageKey={showInstalledFolders ? `paperclip:skills-folder-nudge:${selectedCompanyId ?? "none"}` : undefined}
showBrowseRails={browseRailsEnabled}
/>
</>
) : activeView === "installed" && selectedSkillId ? (
<SkillDetailPage
detail={activeDetail}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Costs } from "./Costs";
const budgetOverviewMock = vi.hoisted(() => vi.fn());
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
const costsApiMocks = vi.hoisted(() => ({
summary: vi.fn(),
byAgent: vi.fn(),
byProject: vi.fn(),
byAgentModel: vi.fn(),
financeSummary: vi.fn(),
financeByBiller: vi.fn(),
financeByKind: vi.fn(),
financeEvents: vi.fn(),
byProvider: vi.fn(),
byBiller: vi.fn(),
windowSpend: vi.fn(),
quotaWindows: vi.fn(),
}));
vi.mock("../api/budgets", () => ({
budgetsApi: {
overview: (...args: unknown[]) => budgetOverviewMock(...args),
upsertPolicy: vi.fn(),
resolveIncident: vi.fn(),
},
}));
vi.mock("../api/costs", () => ({ costsApi: costsApiMocks }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("Costs embedded Audit surfaces", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
budgetOverviewMock.mockResolvedValue({
policies: [],
activeIncidents: [],
pendingApprovalCount: 0,
pausedAgentCount: 0,
pausedProjectCount: 0,
});
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
it("renders a focused Budgets section without duplicate Costs chrome or spend queries", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Costs embedded initialTab="budgets" lockTab />
</QueryClientProvider>,
);
await Promise.resolve();
});
await act(async () => {
await vi.waitFor(() => {
expect(budgetOverviewMock).toHaveBeenCalledWith("company-1");
expect(container.textContent).toContain("Budget control plane");
});
});
expect(container.textContent).not.toContain("Inference spend");
expect(container.querySelector('[role="tab"]')).toBeFalsy();
expect(setBreadcrumbsMock).not.toHaveBeenCalled();
for (const mock of Object.values(costsApiMocks)) expect(mock).not.toHaveBeenCalled();
});
});

View File

@ -34,6 +34,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const NO_COMPANY = "__none__";
export type CostsMainTab = "overview" | "budgets" | "providers" | "billers" | "finance";
export interface CostsProps {
/** Render inside Audit without a second page-level title or breadcrumb. */
embedded?: boolean;
initialTab?: CostsMainTab;
/** Pin the surface to one tab (used by Audit > Budgets). */
lockTab?: boolean;
/** Budgets is a peer Audit section, so omit it from the Costs sub-navigation. */
hideBudgetsTab?: boolean;
}
function currentWeekRange(): { from: string; to: string } {
const now = new Date();
@ -146,14 +157,20 @@ function FinanceSummaryCard({
);
}
export function Costs() {
export function Costs({
embedded = false,
initialTab = "overview",
lockTab = false,
hideBudgetsTab = false,
}: CostsProps = {}) {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [mainTab, setMainTab] = useState<"overview" | "budgets" | "providers" | "billers" | "finance">("overview");
const [mainTab, setMainTab] = useState<CostsMainTab>(initialTab);
const [activeProvider, setActiveProvider] = useState("all");
const [activeBiller, setActiveBiller] = useState("all");
const showSummaryChrome = !(embedded && lockTab && initialTab === "budgets");
const {
preset,
@ -168,8 +185,12 @@ export function Costs() {
} = useDateRange();
useEffect(() => {
setBreadcrumbs([{ label: "Costs" }]);
}, [setBreadcrumbs]);
if (!embedded) setBreadcrumbs([{ label: "Costs" }]);
}, [embedded, setBreadcrumbs]);
useEffect(() => {
setMainTab(initialTab);
}, [initialTab]);
const [today, setToday] = useState(() => new Date().toDateString());
const todayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -240,7 +261,7 @@ export function Costs() {
]);
return { summary, byAgent, byProject, byAgentModel };
},
enabled: !!selectedCompanyId && customReady,
enabled: !!selectedCompanyId && customReady && showSummaryChrome,
});
const { data: financeData, isLoading: financeLoading, error: financeError } = useQuery({
@ -259,7 +280,7 @@ export function Costs() {
]);
return { summary, byBiller, byKind, events };
},
enabled: !!selectedCompanyId && customReady,
enabled: !!selectedCompanyId && customReady && showSummaryChrome,
});
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
@ -535,16 +556,20 @@ export function Costs() {
const showCustomPrompt = preset === "custom" && !customReady;
const showOverviewLoading = (spendLoading || financeLoading) && customReady;
const overviewError = spendError ?? financeError;
return (
<div className="space-y-6">
<div className="space-y-5">
{showSummaryChrome ? (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
{embedded ? (
<h2 className="text-lg font-semibold text-foreground">Costs</h2>
) : (
<h1 className="text-3xl font-semibold tracking-tight">Costs</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Inference spend, platform fees, credits, and live quota windows.
</p>
)}
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Inference spend, platform fees, credits, and live quota windows.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@ -616,16 +641,19 @@ export function Costs() {
icon={ArrowUpRight}
/>
</div>
</div>
</div>
) : null}
<Tabs value={mainTab} onValueChange={(value) => setMainTab(value as typeof mainTab)}>
<TabsList variant="line" className="justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="budgets">Budgets</TabsTrigger>
<TabsTrigger value="providers">Providers</TabsTrigger>
<TabsTrigger value="billers">Billers</TabsTrigger>
<TabsTrigger value="finance">Finance</TabsTrigger>
</TabsList>
{!lockTab ? (
<TabsList variant="line" className="justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
{!hideBudgetsTab ? <TabsTrigger value="budgets">Budgets</TabsTrigger> : null}
<TabsTrigger value="providers">Providers</TabsTrigger>
<TabsTrigger value="billers">Billers</TabsTrigger>
<TabsTrigger value="finance">Finance</TabsTrigger>
</TabsList>
) : null}
<TabsContent value="overview" className="mt-4 space-y-4">
{showCustomPrompt ? (

View File

@ -149,7 +149,9 @@ import {
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import type { CompanySecret, EnvBinding, Issue } from "@paperclipai/shared";
import { CollectionToolbar } from "@/components/CollectionToolbar";
import { IssueRow } from "@/components/IssueRow";
import {
EnvInputsList,
ExternalSourcesList,
@ -239,6 +241,15 @@ const DESIGN_GUIDE_DEGRADED_OUTPUTS: IssueWorkProduct[] = [
} as IssueWorkProduct,
];
const DESIGN_GUIDE_TASK = {
id: "design-guide-task",
identifier: "PAP-427",
title: "Reconcile the navigation model across operator surfaces",
status: "in_progress",
priority: "medium",
blockerAttention: false,
} as unknown as Issue;
/* ------------------------------------------------------------------ */
/* Section wrapper */
/* ------------------------------------------------------------------ */
@ -494,7 +505,8 @@ export function DesignGuide() {
"StatusBadge", "StatusIcon", "PriorityIcon", "EntityRow", "EmptyState", "MetricCard",
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip", "CollectionToolbar",
"IssueRow", "ContextualSidebarFrame",
].map((name) => (
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
{name}
@ -505,6 +517,30 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Task Collection">
<p className="max-w-prose text-sm text-muted-foreground">
CollectionToolbar owns shared geometry while each page owns its state and behavior.
The canonical task row is opt-in during migration: status leads, unread work uses
title emphasis, metadata remains stable, and the task identifier trails.
</p>
<CollectionToolbar
context={<span className="text-sm font-medium">Recent tasks</span>}
search={<Input aria-label="Search task collection example" placeholder="Search tasks..." />}
controls={<Button variant="outline" size="sm">Filter</Button>}
actions={<Button size="sm">New task</Button>}
feedback={<span className="text-xs text-muted-foreground">1 task · Updated newest first</span>}
/>
<div className="overflow-hidden rounded-lg border border-border">
<IssueRow
issue={DESIGN_GUIDE_TASK}
presentation="task"
unreadState="visible"
metadata={<span className="text-xs text-muted-foreground">Updated 12m ago</span>}
actions={<Button variant="ghost" size="xs">More</Button>}
/>
</div>
</Section>
{/* ============================================================ */}
{/* COLORS */}
{/* ============================================================ */}

View File

@ -0,0 +1,641 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { agentUrl } from "../lib/utils";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
// Layout constants
const CARD_W = 200;
const CARD_H = 100;
const GAP_X = 32;
const GAP_Y = 80;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 2;
const TOUCH_MOVE_THRESHOLD = 6;
// ── Tree layout types ───────────────────────────────────────────────────
interface LayoutNode {
id: string;
name: string;
role: string;
status: string;
x: number;
y: number;
children: LayoutNode[];
}
interface Point {
x: number;
y: number;
}
interface TouchGesture {
mode: "pan" | "pinch" | null;
startPoint: Point;
startPan: Point;
startZoom: number;
startDistance: number;
startCenter: Point;
moved: boolean;
}
// ── Layout algorithm ────────────────────────────────────────────────────
/** Compute the width each subtree needs. */
function subtreeWidth(node: OrgNode): number {
if (node.reports.length === 0) return CARD_W;
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
return Math.max(CARD_W, childrenW + gaps);
}
/** Recursively assign x,y positions. */
function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
const totalW = subtreeWidth(node);
const layoutChildren: LayoutNode[] = [];
if (node.reports.length > 0) {
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
let cx = x + (totalW - childrenW - gaps) / 2;
for (const child of node.reports) {
const cw = subtreeWidth(child);
layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y));
cx += cw + GAP_X;
}
}
return {
id: node.id,
name: node.name,
role: node.role,
status: node.status,
x: x + (totalW - CARD_W) / 2,
y,
children: layoutChildren,
};
}
/** Layout all root nodes side by side. */
function layoutForest(roots: OrgNode[]): LayoutNode[] {
if (roots.length === 0) return [];
const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0);
const gaps = (roots.length - 1) * GAP_X;
let x = PADDING;
const y = PADDING;
const result: LayoutNode[] = [];
for (const root of roots) {
const w = subtreeWidth(root);
result.push(layoutTree(root, x, y));
x += w + GAP_X;
}
// Compute bounds and return
return result;
}
/** Flatten layout tree to list of nodes. */
function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
const result: LayoutNode[] = [];
function walk(n: LayoutNode) {
result.push(n);
n.children.forEach(walk);
}
nodes.forEach(walk);
return result;
}
/** Collect all parent→child edges. */
function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> {
const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = [];
function walk(n: LayoutNode) {
for (const c of n.children) {
edges.push({ parent: n, child: c });
walk(c);
}
}
nodes.forEach(walk);
return edges;
}
function clampZoom(value: number): number {
return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM);
}
function touchPoint(touch: React.Touch): Point {
return { x: touch.clientX, y: touch.clientY };
}
function touchDistance(a: React.Touch, b: React.Touch): number {
const dx = a.clientX - b.clientX;
const dy = a.clientY - b.clientY;
return Math.hypot(dx, dy);
}
function touchCenter(a: React.Touch, b: React.Touch, container: HTMLDivElement): Point {
const rect = container.getBoundingClientRect();
return {
x: (a.clientX + b.clientX) / 2 - rect.left,
y: (a.clientY + b.clientY) / 2 - rect.top,
};
}
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const statusDotColor: Record<string, string> = {
running: "var(--hex-22d3ee)",
active: "var(--hex-4ade80)",
paused: "var(--hex-facc15)",
idle: "var(--hex-facc15)",
error: "var(--hex-f87171)",
terminated: "var(--hex-a3a3a3)",
};
const defaultDotColor = "var(--hex-a3a3a3)";
// ── Main component ──────────────────────────────────────────────────────
export function OrgChart() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
// button is hidden rather than dead-ending. Export stays available. Both
// buttons also respect the operator-hidden settings registry.
const isCloud = Boolean(useCloudInstance());
const { hidden: hiddenSettings } = useHiddenSettings();
const showImport = !isCloud && !hiddenSettings.has("company.import");
const showExport = !hiddenSettings.has("company.export");
const { data: orgTree, isLoading } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentMap = useMemo(() => {
const m = new Map<string, Agent>();
for (const a of agents ?? []) m.set(a.id, a);
return m;
}, [agents]);
useEffect(() => {
setBreadcrumbs([{ label: "Org Chart" }]);
}, [setBreadcrumbs]);
// Layout computation
const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]);
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
const edges = useMemo(() => collectEdges(layout), [layout]);
// Compute SVG bounds
const bounds = useMemo(() => {
if (allNodes.length === 0) return { width: 800, height: 600 };
let maxX = 0, maxY = 0;
for (const n of allNodes) {
maxX = Math.max(maxX, n.x + CARD_W);
maxY = Math.max(maxY, n.y + CARD_H);
}
return { width: maxX + PADDING, height: maxY + PADDING };
}, [allNodes]);
// Pan & zoom state
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [dragging, setDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const touchGesture = useRef<TouchGesture>({
mode: null,
startPoint: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
startZoom: 1,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
});
const suppressNextCardClick = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
};
}, []);
// Center the chart on first load
const hasInitialized = useRef(false);
useEffect(() => {
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
hasInitialized.current = true;
const container = containerRef.current;
const containerW = container.clientWidth;
const containerH = container.clientHeight;
// Fit chart to container
const scaleX = (containerW - 40) / bounds.width;
const scaleY = (containerH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({
x: (containerW - chartW) / 2,
y: (containerH - chartH) / 2,
});
}, [allNodes, bounds]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Don't drag if clicking a card
const target = e.target as HTMLElement;
if (target.closest("[data-org-card]")) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
}, [dragging]);
const handleMouseUp = useCallback(() => {
setDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = clampZoom(zoom * factor);
// Zoom toward mouse position
const scale = newZoom / zoom;
setPan({
x: mouseX - scale * (mouseX - pan.x),
y: mouseY - scale * (mouseY - pan.y),
});
setZoom(newZoom);
}, [zoom, pan]);
const zoomTowardPoint = useCallback((newZoom: number, point: Point) => {
const clampedZoom = clampZoom(newZoom);
const scale = clampedZoom / zoom;
setPan({
x: point.x - scale * (point.x - pan.x),
y: point.y - scale * (point.y - pan.y),
});
setZoom(clampedZoom);
}, [zoom, pan]);
const fitToScreen = useCallback(() => {
if (!containerRef.current) return;
const cW = containerRef.current.clientWidth;
const cH = containerRef.current.clientHeight;
const scaleX = (cW - 40) / bounds.width;
const scaleY = (cH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
}, [bounds]);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length >= 2 && containerRef.current) {
const [first, second] = [e.touches[0]!, e.touches[1]!];
touchGesture.current = {
mode: "pinch",
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: touchDistance(first, second),
startCenter: touchCenter(first, second, containerRef.current),
moved: false,
};
return;
}
const touch = e.touches[0];
if (!touch) return;
touchGesture.current = {
mode: "pan",
startPoint: touchPoint(touch),
startPan: pan,
startZoom: zoom,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
};
}, [pan, zoom]);
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container || !touchGesture.current.mode) return;
if (e.touches.length >= 2) {
const [first, second] = [e.touches[0]!, e.touches[1]!];
const distance = touchDistance(first, second);
const center = touchCenter(first, second, container);
if (touchGesture.current.mode !== "pinch" || touchGesture.current.startDistance === 0) {
touchGesture.current = {
mode: "pinch",
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: distance,
startCenter: center,
moved: false,
};
return;
}
const gesture = touchGesture.current;
const nextZoom = clampZoom(gesture.startZoom * (distance / gesture.startDistance));
const scale = nextZoom / gesture.startZoom;
const dx = center.x - gesture.startCenter.x;
const dy = center.y - gesture.startCenter.y;
gesture.moved =
gesture.moved ||
Math.abs(distance - gesture.startDistance) > TOUCH_MOVE_THRESHOLD ||
Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD;
setZoom(nextZoom);
setPan({
x: center.x - scale * (gesture.startCenter.x - gesture.startPan.x),
y: center.y - scale * (gesture.startCenter.y - gesture.startPan.y),
});
return;
}
const touch = e.touches[0];
if (!touch || touchGesture.current.mode !== "pan") return;
const dx = touch.clientX - touchGesture.current.startPoint.x;
const dy = touch.clientY - touchGesture.current.startPoint.y;
touchGesture.current.moved = touchGesture.current.moved || Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD;
setPan({
x: touchGesture.current.startPan.x + dx,
y: touchGesture.current.startPan.y + dy,
});
}, [pan, zoom]);
const handleTouchEnd = useCallback(() => {
if (touchGesture.current.moved) {
suppressNextCardClick.current = true;
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
suppressClickTimerRef.current = window.setTimeout(() => {
suppressNextCardClick.current = false;
suppressClickTimerRef.current = null;
}, 400);
}
touchGesture.current = {
mode: null,
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
};
}, [pan, zoom]);
if (!selectedCompanyId) {
return <EmptyState icon={Network} message="Select a company to view the org chart." />;
}
if (isLoading) {
return <PageSkeleton variant="org-chart" />;
}
if (orgTree && orgTree.length === 0) {
return <EmptyState icon={Network} message="No organizational hierarchy defined." />;
}
return (
<div className="flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0">
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport && (
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
)}
{showExport && (
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
)}
</div>
<div
ref={containerRef}
data-testid="org-chart-viewport"
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{
cursor: dragging ? "grabbing" : "grab",
touchAction: "none",
overscrollBehavior: "contain",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{/* Zoom controls */}
<div className="absolute top-3 right-3 z-10 flex flex-col gap-1.5">
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 1.2, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom in"
aria-label="Zoom in"
>
<Plus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 0.8, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom out"
aria-label="Zoom out"
>
<Minus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-(length:--text-nano) transition-colors hover:bg-accent sm:size-7"
onClick={fitToScreen}
title="Fit to screen"
aria-label="Fit chart to screen"
>
<Maximize2 className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
</div>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
style={{
width: "100%",
height: "100%",
}}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{edges.map(({ parent, child }) => {
const x1 = parent.x + CARD_W / 2;
const y1 = parent.y + CARD_H;
const x2 = child.x + CARD_W / 2;
const y2 = child.y;
const midY = (y1 + y2) / 2;
return (
<path
key={`${parent.id}-${child.id}`}
d={`M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`}
fill="none"
stroke="var(--border)"
strokeWidth={1.5}
/>
);
})}
</g>
</svg>
{/* Card layer */}
<div
data-testid="org-chart-card-layer"
className="absolute inset-0"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0",
}}
>
{allNodes.map((node) => {
const agent = agentMap.get(node.id);
const dotColor = statusDotColor[node.status] ?? defaultDotColor;
return (
<Card
key={node.id}
data-org-card
className="block absolute py-0 hover:shadow-md hover:border-foreground/20 transition-(--tp-box-shadow-border-color) duration-150 cursor-pointer select-none"
style={{
left: node.x,
top: node.y,
width: CARD_W,
minHeight: CARD_H,
}}
onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
onClickCapture={(e) => {
if (!suppressNextCardClick.current) return;
suppressNextCardClick.current = false;
e.preventDefault();
e.stopPropagation();
}}
>
<div className="flex items-center px-4 py-3 gap-3">
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentIcon icon={agent?.icon} className="h-4.5 w-4.5 text-foreground/70" />
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"
style={{ backgroundColor: dotColor }}
/>
</div>
{/* Name + role + adapter type */}
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="text-sm font-semibold text-foreground leading-tight">
{node.name}
</span>
<span className="text-(length:--text-micro) text-muted-foreground leading-tight mt-0.5">
{agent?.title ?? roleLabel(node.role)}
</span>
{agent && (
<span className="text-(length:--text-nano) text-muted-foreground/60 font-mono leading-tight mt-1">
{getAdapterLabel(agent.adapterType)}
</span>
)}
{agent && agent.capabilities && (
<span className="text-(length:--text-nano) text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
{agent.capabilities}
</span>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</div>
);
}
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
function roleLabel(role: string): string {
return roleLabels[role] ?? role;
}

View File

@ -129,6 +129,8 @@ describe("OrgChart mobile gestures", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let queryClient: QueryClient;
let viewportWidth: number;
let viewportHeight: number;
beforeEach(() => {
container = document.createElement("div");
@ -136,19 +138,21 @@ describe("OrgChart mobile gestures", () => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
viewportWidth = 360;
viewportHeight = 520;
orgMock.mockResolvedValue(orgTree);
listMock.mockResolvedValue(agents);
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get() {
return this.getAttribute("data-testid") === "org-chart-viewport" ? 360 : 0;
return this.getAttribute("data-testid") === "org-chart-viewport" ? viewportWidth : 0;
},
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this.getAttribute("data-testid") === "org-chart-viewport" ? 520 : 0;
return this.getAttribute("data-testid") === "org-chart-viewport" ? viewportHeight : 0;
},
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getRect(this: HTMLElement) {
@ -158,10 +162,10 @@ describe("OrgChart mobile gestures", () => {
y: 0,
left: 0,
top: 0,
right: 360,
bottom: 520,
width: 360,
height: 520,
right: viewportWidth,
bottom: viewportHeight,
width: viewportWidth,
height: viewportHeight,
toJSON: () => ({}),
};
}
@ -264,6 +268,19 @@ describe("OrgChart mobile gestures", () => {
expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)");
});
it("does not produce a negative zoom while the viewport has no usable height", async () => {
viewportHeight = 2;
const { layer } = await renderOrgChart();
expect(layer.style.transform).toBe("translate(0px, 0px) scale(1)");
await act(async () => {
(container.querySelector('[aria-label="Fit chart to screen"]') as HTMLButtonElement).click();
});
expect(layer.style.transform).toBe("translate(0px, 0px) scale(1)");
});
it("shows both portability buttons on self-hosted instances", async () => {
await renderOrgChart();

View File

@ -24,6 +24,7 @@ const GAP_Y = 80;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 2;
const FIT_PADDING = 40;
const TOUCH_MOVE_THRESHOLD = 6;
// ── Tree layout types ───────────────────────────────────────────────────
@ -139,6 +140,28 @@ function clampZoom(value: number): number {
return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM);
}
function fitChartToViewport(
containerWidth: number,
containerHeight: number,
bounds: { width: number; height: number },
): { zoom: number; pan: Point } | null {
if (containerWidth <= FIT_PADDING || containerHeight <= FIT_PADDING) return null;
const scaleX = (containerWidth - FIT_PADDING) / bounds.width;
const scaleY = (containerHeight - FIT_PADDING) / bounds.height;
const zoom = clampZoom(Math.min(scaleX, scaleY, 1));
const chartWidth = bounds.width * zoom;
const chartHeight = bounds.height * zoom;
return {
zoom,
pan: {
x: (containerWidth - chartWidth) / 2,
y: (containerHeight - chartHeight) / 2,
},
};
}
function touchPoint(touch: React.Touch): Point {
return { x: touch.clientX, y: touch.clientY };
}
@ -173,7 +196,16 @@ const defaultDotColor = "var(--hex-a3a3a3)";
// ── Main component ──────────────────────────────────────────────────────
export function OrgChart() {
export interface OrgChartProps {
/** Pre-filtered tree for embedding the chart in another collection page. */
orgTree?: OrgNode[];
/** Agent records paired with a pre-filtered embedded tree. */
agents?: Agent[];
/** Hides page-level actions and breadcrumb ownership. */
embedded?: boolean;
}
export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, embedded = false }: OrgChartProps = {}) {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
@ -185,17 +217,19 @@ export function OrgChart() {
const showImport = !isCloud && !hiddenSettings.has("company.import");
const showExport = !hiddenSettings.has("company.export");
const { data: orgTree, isLoading } = useQuery({
const { data: queriedOrgTree, isLoading } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && providedOrgTree === undefined,
});
const { data: agents } = useQuery({
const { data: queriedAgents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && providedAgents === undefined,
});
const orgTree = providedOrgTree ?? queriedOrgTree;
const agents = providedAgents ?? queriedAgents;
const agentMap = useMemo(() => {
const m = new Map<string, Agent>();
@ -204,8 +238,8 @@ export function OrgChart() {
}, [agents]);
useEffect(() => {
setBreadcrumbs([{ label: "Org Chart" }]);
}, [setBreadcrumbs]);
if (!embedded) setBreadcrumbs([{ label: "Org Chart" }]);
}, [embedded, setBreadcrumbs]);
// Layout computation
const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]);
@ -251,27 +285,19 @@ export function OrgChart() {
// Center the chart on first load
const hasInitialized = useRef(false);
useEffect(() => {
hasInitialized.current = false;
}, [orgTree]);
useEffect(() => {
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
hasInitialized.current = true;
const container = containerRef.current;
const containerW = container.clientWidth;
const containerH = container.clientHeight;
const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds);
if (!fitted) return;
// Fit chart to container
const scaleX = (containerW - 40) / bounds.width;
const scaleY = (containerH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({
x: (containerW - chartW) / 2,
y: (containerH - chartH) / 2,
});
hasInitialized.current = true;
setZoom(fitted.zoom);
setPan(fitted.pan);
}, [allNodes, bounds]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
@ -327,15 +353,15 @@ export function OrgChart() {
const fitToScreen = useCallback(() => {
if (!containerRef.current) return;
const cW = containerRef.current.clientWidth;
const cH = containerRef.current.clientHeight;
const scaleX = (cW - 40) / bounds.width;
const scaleY = (cH - 40) / bounds.height;
const fitZoom = Math.min(scaleX, scaleY, 1);
const chartW = bounds.width * fitZoom;
const chartH = bounds.height * fitZoom;
setZoom(fitZoom);
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
const fitted = fitChartToViewport(
containerRef.current.clientWidth,
containerRef.current.clientHeight,
bounds,
);
if (!fitted) return;
setZoom(fitted.zoom);
setPan(fitted.pan);
}, [bounds]);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
@ -442,7 +468,7 @@ export function OrgChart() {
return <EmptyState icon={Network} message="Select an organization to view the org chart." />;
}
if (isLoading) {
if (providedOrgTree === undefined && isLoading) {
return <PageSkeleton variant="org-chart" />;
}
@ -451,25 +477,31 @@ export function OrgChart() {
}
return (
<div className="flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0">
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport && (
<div
className={embedded
? "flex min-h-(--sz-420px) flex-1 flex-col md:min-h-0"
: "flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0"}
>
{!embedded && (showImport || showExport) ? (
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport ? (
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import organization
</Button>
</Link>
)}
{showExport && (
) : null}
{showExport ? (
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export organization
</Button>
</Link>
)}
</div>
) : null}
</div>
) : null}
<div
ref={containerRef}
data-testid="org-chart-viewport"

View File

@ -246,6 +246,28 @@ describe("ProjectDetail", () => {
});
});
it("keeps Timeline out of the project task-list controls", async () => {
mockLocation.pathname = "/projects/project-1/issues";
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
const props = mockIssuesList.mock.calls.at(-1)?.[0];
expect(props).toEqual(expect.objectContaining({ projectId: "project-1" }));
expect(props).not.toHaveProperty("projectTimelineHref");
});
describe("plugin detail-tab deep links", () => {
const PLUGIN_TAB = "plugin:paperclipai.plugin-llm-wiki:project-knowledge";
const knowledgeSlot = {

View File

@ -0,0 +1,926 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Repeat, Sparkles } from "lucide-react";
import { ApiError } from "../api/client";
import {
routinesApi,
type RoutineTriggerResponse,
type RotateRoutineTriggerResponse,
type RestoreRoutineRevisionResponse,
} from "../api/routines";
import { secretsApi } from "../api/secrets";
import { type RoutineHistoryDirtyFieldDescriptor } from "../components/RoutineHistoryTab";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { accessApi } from "../api/access";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { type InlineEntityOption } from "../components/InlineEntitySelector";
import { type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RunButton } from "../components/AgentActionButtons";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { Badge } from "@/components/ui/badge";
import {
RoutineSubSidebar,
RoutineSectionPicker,
} from "../components/RoutineSubSidebar";
import { RoutineSaveBar } from "../components/RoutineSaveBar";
import {
EDITABLE_SECTIONS,
ROUTINE_SECTION_KEYS,
SECTION_FIELD_KEYS,
RoutineDetailContext,
createDefaultNewTrigger,
type RoutineDetailContextValue,
type RoutineEditDraft,
type RoutineSectionKey,
type SecretMessage,
} from "../components/routine-sections/context";
import {
OverviewSection,
TriggersSection,
VariablesSection,
SecretsSection,
DeliverySection,
} from "../components/routine-sections/editable-sections.production";
import {
RunsSection,
ActivitySection,
HistorySection,
} from "../components/routine-sections/operate-sections";
import type {
RoutineDetail as RoutineDetailType,
RoutineEnvConfig,
RoutineVariable,
} from "@paperclipai/shared";
const LAST_SECTION_STORAGE_KEY = "paperclip.routineLastSection";
export function buildRoutineProjectOptions(
projects: ReadonlyArray<{ id: string; name: string; description?: string | null; archivedAt?: Date | string | null }>,
): InlineEntityOption[] {
return projects
.filter((project) => !project.archivedAt)
.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
}));
}
const SECTION_TITLES: Record<RoutineSectionKey, string> = {
overview: "Overview",
triggers: "Triggers",
variables: "Variables",
secrets: "Secrets",
delivery: "Delivery",
runs: "Runs",
activity: "Activity",
history: "History",
};
function isRoutineSection(value: string | undefined | null): value is RoutineSectionKey {
return value != null && ROUTINE_SECTION_KEYS.includes(value as RoutineSectionKey);
}
function readLastSection(routineId: string): RoutineSectionKey | null {
try {
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, string>;
const stored = parsed[routineId];
return isRoutineSection(stored) ? stored : null;
} catch {
return null;
}
}
function writeLastSection(routineId: string, section: RoutineSectionKey) {
try {
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as Record<string, string>) : {};
parsed[routineId] = section;
localStorage.setItem(LAST_SECTION_STORAGE_KEY, JSON.stringify(parsed));
} catch {
/* ignore storage failures */
}
}
/** Back-compat: `?tab=x` query param maps to the new section sub-routes. */
const LEGACY_TAB_TO_SECTION: Record<string, RoutineSectionKey> = {
triggers: "triggers",
runs: "runs",
activity: "activity",
secrets: "secrets",
history: "history",
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}
function getLocalTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "UTC";
}
}
function buildRoutineMutationPayload(input: RoutineEditDraft) {
return {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
assigneeAgentId: input.assigneeAgentId || null,
env: input.env && Object.keys(input.env).length > 0 ? input.env : null,
};
}
export function RoutineDetail() {
const { routineId, section: sectionParam } = useParams<{ routineId: string; section?: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToastActions();
const hydratedRoutineIdRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [saveConflict, setSaveConflict] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
const [newTrigger, setNewTrigger] = useState(createDefaultNewTrigger);
const [editDraft, setEditDraft] = useState<RoutineEditDraft>({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
variables: [],
env: null,
});
const section: RoutineSectionKey = isRoutineSection(sectionParam) ? sectionParam : "overview";
const navigateToSection = useCallback(
(next: RoutineSectionKey, options?: { replace?: boolean }) => {
if (!routineId) return;
writeLastSection(routineId, next);
navigate(`/routines/${routineId}/${next}`, { replace: options?.replace ?? true });
},
[navigate, routineId],
);
const { data: routine, isLoading, error } = useQuery({
queryKey: queryKeys.routines.detail(routineId!),
queryFn: () => routinesApi.get(routineId!),
enabled: !!routineId,
});
const activeIssueId = routine?.activeIssue?.id;
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(activeIssueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(activeIssueId!),
enabled: !!activeIssueId,
refetchInterval: 3000,
});
const hasLiveRun = (liveRuns ?? []).length > 0;
const { data: routineRuns } = useQuery({
queryKey: queryKeys.routines.runs(routineId!),
queryFn: () => routinesApi.listRuns(routineId!),
enabled: !!routineId,
refetchInterval: hasLiveRun ? 3000 : false,
});
const relatedActivityIds = useMemo(
() => ({
triggerIds: routine?.triggers.map((trigger) => trigger.id) ?? [],
runIds: routineRuns?.map((run) => run.id) ?? [],
}),
[routine?.triggers, routineRuns],
);
const { data: activity } = useQuery({
queryKey: [
...queryKeys.routines.activity(selectedCompanyId!, routineId!),
relatedActivityIds.triggerIds.join(","),
relatedActivityIds.runIds.join(","),
],
queryFn: () => routinesApi.activity(selectedCompanyId!, routineId!, relatedActivityIds),
enabled: !!selectedCompanyId && !!routineId && !!routine,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const routineDefaults = useMemo<RoutineEditDraft | null>(
() =>
routine
? {
title: routine.title,
description: routine.description ?? "",
projectId: routine.projectId ?? "",
assigneeAgentId: routine.assigneeAgentId ?? "",
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
activityGatePolicy: routine.activityGatePolicy,
activityGateScope: routine.activityGateScope,
variables: routine.variables,
env: routine.env ?? null,
}
: null,
[routine],
);
const dirtyFields = useMemo<RoutineHistoryDirtyFieldDescriptor[]>(() => {
if (!routineDefaults) return [];
const result: RoutineHistoryDirtyFieldDescriptor[] = [];
if (editDraft.title !== routineDefaults.title) result.push({ key: "title", label: "the title" });
if (editDraft.description !== routineDefaults.description) {
result.push({ key: "description", label: "the description" });
}
if (editDraft.projectId !== routineDefaults.projectId) {
result.push({ key: "projectId", label: "the project" });
}
if (editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId) {
result.push({ key: "assigneeAgentId", label: "the default agent" });
}
if (editDraft.priority !== routineDefaults.priority) {
result.push({ key: "priority", label: "the priority" });
}
if (editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy) {
result.push({ key: "concurrencyPolicy", label: "the concurrency policy" });
}
if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) {
result.push({ key: "catchUpPolicy", label: "the catch-up policy" });
}
if (editDraft.activityGatePolicy !== routineDefaults.activityGatePolicy) {
result.push({ key: "activityGatePolicy", label: "the advanced run policy" });
}
if (editDraft.activityGateScope !== routineDefaults.activityGateScope) {
result.push({ key: "activityGateScope", label: "the activity gate scope" });
}
if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) {
result.push({ key: "variables", label: "the variables" });
}
if (JSON.stringify(editDraft.env ?? null) !== JSON.stringify(routineDefaults.env ?? null)) {
result.push({ key: "env", label: "the secrets" });
}
return result;
}, [editDraft, routineDefaults]);
const isEditDirty = dirtyFields.length > 0;
const sectionDirtyFields = useCallback(
(target: RoutineSectionKey) => {
const keys = SECTION_FIELD_KEYS[target];
if (!keys) return [];
return dirtyFields.filter((field) => keys.includes(field.key));
},
[dirtyFields],
);
const isSectionDirty = useCallback(
(target: RoutineSectionKey) => sectionDirtyFields(target).length > 0,
[sectionDirtyFields],
);
const discardSection = useCallback(
(target: RoutineSectionKey) => {
if (!routineDefaults) return;
const keys = SECTION_FIELD_KEYS[target];
if (!keys) return;
setEditDraft((current) => {
const next = { ...current } as Record<string, unknown>;
for (const key of keys) {
next[key] = (routineDefaults as Record<string, unknown>)[key];
}
return next as RoutineEditDraft;
});
},
[routineDefaults],
);
useEffect(() => {
if (!routine) return;
setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]);
if (!routineDefaults) return;
const changedRoutine = hydratedRoutineIdRef.current !== routine.id;
if (changedRoutine || !isEditDirty) {
setEditDraft(routineDefaults);
hydratedRoutineIdRef.current = routine.id;
}
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs]);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [editDraft.title, routine?.id]);
// Persist the section the user lands on so a bare /routines/:id remembers it.
useEffect(() => {
if (routineId && isRoutineSection(sectionParam)) {
writeLastSection(routineId, sectionParam);
}
}, [routineId, sectionParam]);
const copySecretValue = useCallback(
async (label: string, value: string) => {
try {
await copyTextToClipboard(value);
pushToast({ title: `${label} copied`, tone: "success" });
} catch (copyError) {
pushToast({
title: `Failed to copy ${label.toLowerCase()}`,
body: copyError instanceof Error ? copyError.message : "Clipboard access was denied.",
tone: "error",
});
}
},
[pushToast],
);
const saveRoutine = useMutation({
mutationFn: () => {
const payload = buildRoutineMutationPayload(editDraft);
const baseRevisionId = routine?.latestRevisionId ?? null;
return routinesApi.update(routineId!, {
...payload,
...(baseRevisionId ? { baseRevisionId } : {}),
});
},
onSuccess: async () => {
setSaveConflict(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routineId!) }),
]);
},
onError: (mutationError) => {
if (mutationError instanceof ApiError && mutationError.status === 409) {
setSaveConflict(true);
pushToast({
title: "Routine changed",
body: "Someone else updated this routine. Reload to see the latest revision.",
tone: "warn",
});
return;
}
pushToast({
title: "Failed to save routine",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the routine.",
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: (data?: RoutineRunDialogSubmitData) =>
routinesApi.run(routineId!, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
: {}),
...(data?.executionWorkspaceSettings !== undefined
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
: {}),
}),
onSuccess: async () => {
pushToast({ title: "Routine run started", tone: "success" });
setRunVariablesOpen(false);
navigateToSection("runs");
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (runError) => {
pushToast({
title: "Routine run failed",
body: runError instanceof Error ? runError.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
const updateRoutineStatus = useMutation({
mutationFn: (status: string) => routinesApi.update(routineId!, { status }),
onSuccess: async (_data, status) => {
pushToast({
title: "Routine saved",
body: status === "paused" ? "Automation paused." : "Automation enabled.",
tone: "success",
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
]);
},
onError: (statusError) => {
pushToast({
title: "Failed to update routine",
body: statusError instanceof Error ? statusError.message : "Paperclip could not update the routine.",
tone: "error",
});
},
});
const createTrigger = useMutation({
mutationFn: async (): Promise<RoutineTriggerResponse> => {
const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === newTrigger.kind).length;
const autoLabel = existingOfKind > 0 ? `${newTrigger.kind}-${existingOfKind + 1}` : newTrigger.kind;
return routinesApi.createTrigger(routineId!, {
kind: newTrigger.kind,
label: autoLabel,
...(newTrigger.kind === "schedule"
? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() }
: {}),
...(newTrigger.kind === "webhook"
? { signingMode: newTrigger.signingMode, replayWindowSec: Number(newTrigger.replayWindowSec || "300") }
: {}),
});
},
onSuccess: async (result) => {
if (result.secretMaterial) {
setSecretMessage({
title: "Webhook trigger created",
entries: [{ webhookUrl: result.secretMaterial.webhookUrl, webhookSecret: result.secretMaterial.webhookSecret }],
});
} else {
pushToast({ title: "Trigger added", body: "The routine schedule was saved.", tone: "success" });
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (triggerError) => {
pushToast({
title: "Failed to add trigger",
body: triggerError instanceof Error ? triggerError.message : "Paperclip could not create the trigger.",
tone: "error",
});
},
});
const updateTrigger = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
onSuccess: async () => {
pushToast({ title: "Trigger saved", body: "The routine cadence update was saved.", tone: "success" });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (triggerError) => {
pushToast({
title: "Failed to update trigger",
body: triggerError instanceof Error ? triggerError.message : "Paperclip could not update the trigger.",
tone: "error",
});
},
});
const deleteTrigger = useMutation({
mutationFn: (id: string) => routinesApi.deleteTrigger(id),
onSuccess: async () => {
pushToast({ title: "Trigger deleted", tone: "success" });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (triggerError) => {
pushToast({
title: "Failed to delete trigger",
body: triggerError instanceof Error ? triggerError.message : "Paperclip could not delete the trigger.",
tone: "error",
});
},
});
const rotateTrigger = useMutation({
mutationFn: (id: string): Promise<RotateRoutineTriggerResponse> => routinesApi.rotateTriggerSecret(id),
onSuccess: async (result) => {
setSecretMessage({
title: "Webhook secret rotated",
entries: [{ webhookUrl: result.secretMaterial.webhookUrl, webhookSecret: result.secretMaterial.webhookSecret }],
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (triggerError) => {
pushToast({
title: "Failed to rotate webhook secret",
body: triggerError instanceof Error ? triggerError.message : "Paperclip could not rotate the webhook secret.",
tone: "error",
});
},
});
const agentById = useMemo(() => new Map((agents ?? []).map((agent) => [agent.id, agent])), [agents]);
const projectById = useMemo(() => new Map((projects ?? []).map((project) => [project.id, project])), [projects]);
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [routine?.id]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() => buildRoutineProjectOptions(projects ?? []),
[projects],
);
const mentionOptions = useMemo<MentionOption[]>(
() => buildMarkdownMentionOptions({
agents,
projects: (projects ?? []).filter((project) => !project.archivedAt),
members: companyMembers?.users,
}),
[agents, companyMembers?.users, projects],
);
// Wrap track-recent side-effects so the section components stay declarative.
const setEditDraftTracked: typeof setEditDraft = useCallback((updater) => {
setEditDraft((current) => {
const next = typeof updater === "function" ? (updater as (c: RoutineEditDraft) => RoutineEditDraft)(current) : updater;
if (next.assigneeAgentId && next.assigneeAgentId !== current.assigneeAgentId) {
trackRecentAssignee(next.assigneeAgentId);
}
if (next.projectId && next.projectId !== current.projectId) {
trackRecentProject(next.projectId);
}
return next;
});
}, []);
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
const reloadLatest = useCallback(() => {
setSaveConflict(false);
if (routineDefaults) setEditDraft(routineDefaults);
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) });
}, [queryClient, routineDefaults, routineId]);
const onHistoryRestoreSecretMaterials = useCallback((response: RestoreRoutineRevisionResponse) => {
if (response.secretMaterials.length > 0) {
setSecretMessage({
title:
response.secretMaterials.length === 1
? "Webhook trigger restored"
: `${response.secretMaterials.length} webhook triggers restored`,
entries: response.secretMaterials.map((recreated) => ({
webhookUrl: recreated.webhookUrl,
webhookSecret: recreated.webhookSecret,
})),
});
}
}, []);
const onHistoryRestored = useCallback(
(response: RestoreRoutineRevisionResponse) => {
setSaveConflict(false);
queryClient.setQueryData<RoutineDetailType | undefined>(
queryKeys.routines.detail(routineId!),
(prev) =>
prev
? {
...prev,
...response.routine,
latestRevisionId: response.revision.id,
latestRevisionNumber: response.revision.revisionNumber,
}
: prev,
);
setEditDraft({
title: response.routine.title,
description: response.routine.description ?? "",
projectId: response.routine.projectId ?? "",
assigneeAgentId: response.routine.assigneeAgentId ?? "",
priority: response.routine.priority,
concurrencyPolicy: response.routine.concurrencyPolicy,
catchUpPolicy: response.routine.catchUpPolicy,
activityGatePolicy: response.routine.activityGatePolicy,
activityGateScope: response.routine.activityGateScope,
variables: response.routine.variables as RoutineVariable[],
env: (response.routine.env ?? null) as RoutineEnvConfig | null,
});
hydratedRoutineIdRef.current = response.routine.id;
},
[queryClient, routineId],
);
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
// Back-compat redirect: `?tab=x` → `/routines/:id/x`.
const legacyTab = new URLSearchParams(window.location.search).get("tab");
if (routineId && legacyTab && LEGACY_TAB_TO_SECTION[legacyTab]) {
return <Navigate to={`/routines/${routineId}/${LEGACY_TAB_TO_SECTION[legacyTab]}`} replace />;
}
// Bare /routines/:id → remembered section or overview.
if (routineId && !sectionParam) {
const landing = readLastSection(routineId) ?? "overview";
return <Navigate to={`/routines/${routineId}/${landing}`} replace />;
}
// Unknown section → overview.
if (routineId && sectionParam && !isRoutineSection(sectionParam)) {
return <Navigate to={`/routines/${routineId}/overview`} replace />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (error || !routine || !routineDefaults) {
return (
<EmptyState
icon={AlertCircle}
message={error instanceof Error ? error.message : "We couldn't load this routine."}
/>
);
}
const automationEnabled = routine.status === "active";
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
const automationLabel =
routine.status === "archived"
? "Archived"
: !routine.assigneeAgentId
? "Draft"
: automationEnabled
? "Active"
: "Paused";
const automationLabelClassName =
routine.status === "archived"
? "text-muted-foreground"
: automationEnabled
? "text-emerald-400"
: "text-muted-foreground";
const contextValue: RoutineDetailContextValue = {
routine,
routineId: routineId!,
companyId: routine.companyId,
editDraft,
setEditDraft: setEditDraftTracked,
routineDefaults,
dirtyFields,
isEditDirty,
sectionDirtyFields,
isSectionDirty,
discardSection,
saveRoutine,
saveConflict,
reloadLatest,
automationEnabled,
automationLabel,
automationLabelClassName,
automationToggleDisabled,
onToggleAutomation: () => {
if (!automationEnabled && !routine.assigneeAgentId) {
pushToast({
title: "Default agent required",
body: "Set a default agent before enabling routine automation.",
tone: "warn",
});
return;
}
updateRoutineStatus.mutate(automationEnabled ? "paused" : "active");
},
onOpenRunDialog: () => setRunVariablesOpen(true),
runRoutinePending: runRoutine.isPending,
newTrigger,
setNewTrigger,
createTrigger,
updateTrigger,
deleteTrigger,
rotateTrigger,
secretMessage,
setSecretMessage,
copySecretValue,
availableSecrets,
createSecret,
agents: agents ?? [],
projects: projects ?? [],
agentById,
projectById,
assigneeOptions,
projectOptions,
recentAssigneeIds,
recentProjectIds,
mentionOptions,
currentAssignee,
currentProject,
routineRuns,
activity,
hasLiveRun,
activeIssueId,
titleInputRef,
descriptionEditorRef,
assigneeSelectorRef,
projectSelectorRef,
onHistoryRestoreSecretMaterials,
onHistoryRestored,
navigateToSection,
};
const isEditableSection = EDITABLE_SECTIONS.includes(section);
return (
<RoutineDetailContext.Provider value={contextValue}>
<a
href="#routine-section"
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-20 focus:rounded focus:bg-background focus:px-3 focus:py-1.5 focus:text-sm"
>
Skip to section
</a>
{/* Bounded to the main scroll area's height so the header + sub-nav stay
fixed and only the section content below scrolls (no page-level
scroll, no competing sticky points). */}
<div className="-m-4 flex h-full min-h-0 flex-col overflow-hidden md:-m-6">
{/* Slim page header — fixed at the top of the routine layout. */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<textarea
ref={titleInputRef}
data-autosize-title
className="min-w-0 flex-1 resize-none overflow-hidden bg-transparent text-base font-semibold leading-7 outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (section === "overview") {
descriptionEditorRef.current?.focus();
} else {
navigateToSection("overview");
}
}
}}
/>
{routine.managedByPlugin ? (
<Badge variant="outline" className="hidden shrink-0 gap-1.5 text-xs text-muted-foreground sm:inline-flex">
<Sparkles className="h-3 w-3" />
{routine.managedByPlugin.pluginDisplayName}
<span className="font-mono text-(length:--text-nano)">{routine.managedByPlugin.resourceKey}</span>
</Badge>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-3">
<RunButton onClick={() => setRunVariablesOpen(true)} disabled={runRoutine.isPending} />
<div className="flex items-center gap-2">
<ToggleSwitch
size="default"
checked={automationEnabled}
onCheckedChange={contextValue.onToggleAutomation}
disabled={automationToggleDisabled}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
/>
<span className={`text-sm font-medium ${automationLabelClassName}`}>{automationLabel}</span>
</div>
</div>
</header>
{/* Mobile section picker */}
<RoutineSectionPicker
activeSection={section}
onNavigate={navigateToSection}
isSectionDirty={isSectionDirty}
/>
<div className="flex min-h-0 flex-1">
<RoutineSubSidebar
activeSection={section}
hrefFor={(target) => `/routines/${routineId}/${target}`}
isSectionDirty={isSectionDirty}
hasLiveRun={hasLiveRun}
onNavigate={(target) => writeLastSection(routineId!, target)}
/>
<main
id="routine-section"
role="main"
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-6 pt-10 md:px-8"
>
<section
aria-labelledby="routine-section-title"
className={isEditableSection ? "mx-auto w-full max-w-3xl" : "w-full"}
>
<h2 id="routine-section-title" className="mb-4 text-lg font-semibold">
{SECTION_TITLES[section]}
</h2>
{section === "overview" && <OverviewSection />}
{section === "triggers" && <TriggersSection />}
{section === "variables" && <VariablesSection />}
{section === "secrets" && <SecretsSection />}
{section === "delivery" && <DeliverySection />}
{section === "runs" && <RunsSection />}
{section === "activity" && <ActivitySection />}
{section === "history" && <HistorySection />}
{isEditableSection ? (
<RoutineSaveBar
dirtyFields={sectionDirtyFields(section)}
isSaving={saveRoutine.isPending}
saveConflict={saveConflict}
onSave={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) saveRoutine.mutate();
}}
onDiscard={() => discardSection(section)}
onReload={reloadLatest}
/>
) : null}
</section>
</main>
</div>
</div>
<RoutineRunVariablesDialog
open={runVariablesOpen}
onOpenChange={setRunVariablesOpen}
companyId={routine.companyId}
routineName={routine.title}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={routine.projectId}
defaultAssigneeAgentId={routine.assigneeAgentId}
variables={routine.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => runRoutine.mutate(data)}
/>
</RoutineDetailContext.Provider>
);
}

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Repeat, Sparkles } from "lucide-react";
import { AlertCircle, History, Pencil, Repeat, Sparkles, X } from "lucide-react";
import { ApiError } from "../api/client";
import {
routinesApi,
@ -18,6 +18,7 @@ import { accessApi } from "../api/access";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { buildMarkdownMentionOptions } from "../lib/company-members";
@ -34,14 +35,17 @@ import { RunButton } from "../components/AgentActionButtons";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { RoutineOverview } from "../components/RoutineOverview";
import { RoutineSectionPicker, RoutineSubSidebar } from "../components/RoutineSubSidebar";
import {
RoutineSubSidebar,
RoutineSectionPicker,
} from "../components/RoutineSubSidebar";
isRoutineDetailView,
resolveRoutineDetailDestination,
routineDetailHref,
} from "../components/RoutineContextualSidebar";
import { RoutineSaveBar } from "../components/RoutineSaveBar";
import {
EDITABLE_SECTIONS,
ROUTINE_SECTION_KEYS,
SECTION_FIELD_KEYS,
RoutineDetailContext,
createDefaultNewTrigger,
@ -58,9 +62,9 @@ import {
DeliverySection,
} from "../components/routine-sections/editable-sections";
import {
RunsSection,
ActivitySection,
HistorySection,
RunsSection,
} from "../components/routine-sections/operate-sections";
import type {
RoutineDetail as RoutineDetailType,
@ -68,8 +72,6 @@ import type {
RoutineVariable,
} from "@paperclipai/shared";
const LAST_SECTION_STORAGE_KEY = "paperclip.routineLastSection";
export function buildRoutineProjectOptions(
projects: ReadonlyArray<{ id: string; name: string; description?: string | null; archivedAt?: Date | string | null }>,
): InlineEntityOption[] {
@ -84,49 +86,13 @@ export function buildRoutineProjectOptions(
const SECTION_TITLES: Record<RoutineSectionKey, string> = {
overview: "Overview",
triggers: "Triggers",
triggers: "Schedule",
variables: "Variables",
secrets: "Secrets",
delivery: "Delivery",
runs: "Runs",
activity: "Activity",
history: "History",
};
function isRoutineSection(value: string | undefined | null): value is RoutineSectionKey {
return value != null && ROUTINE_SECTION_KEYS.includes(value as RoutineSectionKey);
}
function readLastSection(routineId: string): RoutineSectionKey | null {
try {
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, string>;
const stored = parsed[routineId];
return isRoutineSection(stored) ? stored : null;
} catch {
return null;
}
}
function writeLastSection(routineId: string, section: RoutineSectionKey) {
try {
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as Record<string, string>) : {};
parsed[routineId] = section;
localStorage.setItem(LAST_SECTION_STORAGE_KEY, JSON.stringify(parsed));
} catch {
/* ignore storage failures */
}
}
/** Back-compat: `?tab=x` query param maps to the new section sub-routes. */
const LEGACY_TAB_TO_SECTION: Record<string, RoutineSectionKey> = {
triggers: "triggers",
runs: "runs",
activity: "activity",
secrets: "secrets",
history: "history",
history: "Version history",
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
@ -160,6 +126,7 @@ export function RoutineDetail() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToastActions();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const hydratedRoutineIdRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
@ -168,6 +135,7 @@ export function RoutineDetail() {
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [saveConflict, setSaveConflict] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
const [overviewEditing, setOverviewEditing] = useState(false);
const [newTrigger, setNewTrigger] = useState(createDefaultNewTrigger);
const [editDraft, setEditDraft] = useState<RoutineEditDraft>({
title: "",
@ -183,15 +151,25 @@ export function RoutineDetail() {
env: null,
});
const section: RoutineSectionKey = isRoutineSection(sectionParam) ? sectionParam : "overview";
const legacyOperateSection = !streamlinedUiEnabled && (sectionParam === "runs" || sectionParam === "activity")
? sectionParam
: null;
const section: RoutineSectionKey = legacyOperateSection
?? (isRoutineDetailView(sectionParam) ? sectionParam : "overview");
const navigateToSection = useCallback(
(next: RoutineSectionKey, options?: { replace?: boolean }) => {
if (!routineId) return;
writeLastSection(routineId, next);
navigate(`/routines/${routineId}/${next}`, { replace: options?.replace ?? true });
if (!streamlinedUiEnabled && (next === "runs" || next === "activity")) {
navigate(`/routines/${routineId}/${next}`, { replace: options?.replace ?? true });
return;
}
navigate(
resolveRoutineDetailDestination({ routineId, section: next }),
{ replace: options?.replace ?? true },
);
},
[navigate, routineId],
[navigate, routineId, streamlinedUiEnabled],
);
const { data: routine, isLoading, error } = useQuery({
@ -360,12 +338,9 @@ export function RoutineDetail() {
autoResizeTextarea(titleInputRef.current);
}, [editDraft.title, routine?.id]);
// Persist the section the user lands on so a bare /routines/:id remembers it.
useEffect(() => {
if (routineId && isRoutineSection(sectionParam)) {
writeLastSection(routineId, sectionParam);
}
}, [routineId, sectionParam]);
if (section !== "overview") setOverviewEditing(false);
}, [routineId, section]);
const copySecretValue = useCallback(
async (label: string, value: string) => {
@ -677,20 +652,15 @@ export function RoutineDetail() {
return <EmptyState icon={Repeat} message="Select an organization to view routines." />;
}
// Back-compat redirect: `?tab=x` → `/routines/:id/x`.
const legacyTab = new URLSearchParams(window.location.search).get("tab");
if (routineId && legacyTab && LEGACY_TAB_TO_SECTION[legacyTab]) {
return <Navigate to={`/routines/${routineId}/${LEGACY_TAB_TO_SECTION[legacyTab]}`} replace />;
}
// Bare /routines/:id → remembered section or overview.
if (routineId && !sectionParam) {
const landing = readLastSection(routineId) ?? "overview";
return <Navigate to={`/routines/${routineId}/${landing}`} replace />;
}
// Unknown section → overview.
if (routineId && sectionParam && !isRoutineSection(sectionParam)) {
return <Navigate to={`/routines/${routineId}/overview`} replace />;
const validLegacySection = !streamlinedUiEnabled && (sectionParam === "runs" || sectionParam === "activity");
if (routineId && (legacyTab || !sectionParam || (!isRoutineDetailView(sectionParam) && !validLegacySection))) {
return (
<Navigate
to={resolveRoutineDetailDestination({ routineId, section: sectionParam, legacyTab })}
replace
/>
);
}
if (isLoading) {
@ -801,35 +771,52 @@ export function RoutineDetail() {
Skip to section
</a>
{/* Bounded to the main scroll area's height so the header + sub-nav stay
fixed and only the section content below scrolls (no page-level
scroll, no competing sticky points). */}
<div className="-m-4 flex h-full min-h-0 flex-col overflow-hidden md:-m-6">
{/* Slim page header — fixed at the top of the routine layout. */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-6">
{/* The global shell owns routine navigation. This surface keeps one
content scroll owner and a compact action header. */}
<div className="-m-4 flex h-full min-h-0 overflow-hidden md:-m-6">
{!streamlinedUiEnabled ? (
<RoutineSubSidebar
activeSection={section}
hrefFor={(next) => next === "runs" || next === "activity"
? `/routines/${routine.id}/${next}`
: routineDetailHref(routine.id, next)}
isSectionDirty={isSectionDirty}
hasLiveRun={hasLiveRun}
onNavigate={navigateToSection}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!streamlinedUiEnabled ? (
<RoutineSectionPicker
activeSection={section}
onNavigate={navigateToSection}
isSectionDirty={isSectionDirty}
/>
) : null}
<header className="flex min-h-14 shrink-0 flex-wrap items-center gap-3 border-b border-border bg-background px-4 py-2 md:px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<textarea
ref={titleInputRef}
data-autosize-title
className="min-w-0 flex-1 resize-none overflow-hidden bg-transparent text-base font-semibold leading-7 outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (section === "overview") {
{section === "overview" && overviewEditing ? (
<textarea
ref={titleInputRef}
data-autosize-title
className="min-w-0 flex-1 resize-none overflow-hidden bg-transparent text-base font-semibold leading-7 outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
} else {
navigateToSection("overview");
}
}
}}
/>
}}
/>
) : (
<h1 className="min-w-0 flex-1 truncate text-xl font-bold">{routine.title}</h1>
)}
{routine.managedByPlugin ? (
<Badge variant="outline" className="hidden shrink-0 gap-1.5 text-xs text-muted-foreground sm:inline-flex">
<Sparkles className="h-3 w-3" />
@ -838,7 +825,45 @@ export function RoutineDetail() {
</Badge>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-3">
<div className="ml-auto flex shrink-0 items-center gap-2">
{section === "history" ? (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(routineDetailHref(routine.id))}
>
Back to overview
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(routineDetailHref(routine.id, "history"))}
>
<History className="h-3.5 w-3.5" />
History
</Button>
)}
{section === "overview" ? (
overviewEditing ? (
<Button
variant="outline"
size="sm"
onClick={() => {
discardSection("overview");
setOverviewEditing(false);
}}
>
<X className="h-3.5 w-3.5" />
Cancel editing
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setOverviewEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
Edit routine
</Button>
)
) : null}
<RunButton onClick={() => setRunVariablesOpen(true)} disabled={runRoutine.isPending} />
<div className="flex items-center gap-2">
<ToggleSwitch
@ -853,58 +878,48 @@ export function RoutineDetail() {
</div>
</header>
{/* Mobile section picker */}
<RoutineSectionPicker
activeSection={section}
onNavigate={navigateToSection}
isSectionDirty={isSectionDirty}
/>
<div className="flex min-h-0 flex-1">
<RoutineSubSidebar
activeSection={section}
hrefFor={(target) => `/routines/${routineId}/${target}`}
isSectionDirty={isSectionDirty}
hasLiveRun={hasLiveRun}
onNavigate={(target) => writeLastSection(routineId!, target)}
/>
<main
id="routine-section"
role="main"
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-6 pt-10 md:px-8"
<main
id="routine-section"
role="main"
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-6 pt-8 md:px-8"
>
<section
aria-labelledby="routine-section-title"
className={
section === "overview" && !overviewEditing
? "mx-auto w-full max-w-5xl"
: isEditableSection
? "mx-auto w-full max-w-3xl"
: "w-full"
}
>
<section
aria-labelledby="routine-section-title"
className={isEditableSection ? "mx-auto w-full max-w-3xl" : "w-full"}
>
<h2 id="routine-section-title" className="mb-4 text-lg font-semibold">
{SECTION_TITLES[section]}
</h2>
<h2 id="routine-section-title" className="mb-4 text-lg font-semibold">
{SECTION_TITLES[section]}
</h2>
{section === "overview" && <OverviewSection />}
{section === "triggers" && <TriggersSection />}
{section === "variables" && <VariablesSection />}
{section === "secrets" && <SecretsSection />}
{section === "delivery" && <DeliverySection />}
{section === "runs" && <RunsSection />}
{section === "activity" && <ActivitySection />}
{section === "history" && <HistorySection />}
{section === "overview" && (overviewEditing ? <OverviewSection /> : <RoutineOverview />)}
{section === "triggers" && <TriggersSection />}
{section === "variables" && <VariablesSection />}
{section === "secrets" && <SecretsSection />}
{section === "delivery" && <DeliverySection />}
{section === "runs" && <RunsSection />}
{section === "activity" && <ActivitySection />}
{section === "history" && <HistorySection />}
{isEditableSection ? (
<RoutineSaveBar
dirtyFields={sectionDirtyFields(section)}
isSaving={saveRoutine.isPending}
saveConflict={saveConflict}
onSave={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) saveRoutine.mutate();
}}
onDiscard={() => discardSection(section)}
onReload={reloadLatest}
/>
) : null}
</section>
</main>
{isEditableSection && (section !== "overview" || overviewEditing) ? (
<RoutineSaveBar
dirtyFields={sectionDirtyFields(section)}
isSaving={saveRoutine.isPending}
saveConflict={saveConflict}
onSave={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) saveRoutine.mutate();
}}
onDiscard={() => discardSection(section)}
onReload={reloadLatest}
/>
) : null}
</section>
</main>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
const inlineEntitySelectorRenderMock = vi.fn((props: { options?: Array<{ id: string }> }) => props);
vi.mock("@/lib/router", () => ({
Navigate: ({ to }: { to: string }) => <a data-redirect href={to}>Redirect</a>,
Link: ({ to, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
@ -677,8 +678,7 @@ describe("Routines page", () => {
});
}
const sectionLabels = Array.from(container.querySelectorAll("span"))
.filter((element) => element.className.includes("uppercase") && element.className.includes("tracking-wide"))
const sectionLabels = Array.from(container.querySelectorAll("[data-routine-section-label]"))
.map((element) => element.textContent);
expect(sectionLabels).toEqual(["RPI", "Test", "Unfiled"]);
@ -1034,13 +1034,9 @@ describe("Routines page", () => {
});
});
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
it("keeps the list focused on routines and links run history to Audit", async () => {
currentSearch = "tab=runs";
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
issuesListMock.mockResolvedValue([
createIssue({ id: "issue-1", title: "Routine execution A" }),
createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }),
]);
const root = createRoot(container);
const queryClient = new QueryClient({
@ -1058,13 +1054,9 @@ describe("Routines page", () => {
await flush();
});
for (let attempts = 0; attempts < 5 && issuesListMock.mock.calls.length === 0; attempts += 1) {
await act(async () => {
await flush();
});
}
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
expect(container.querySelector('a[data-redirect][href="/activity/runs"]'))
.not.toBeNull();
expect(issuesListMock).not.toHaveBeenCalled();
await act(async () => {
root.unmount();

View File

@ -1,6 +1,6 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { Link, Navigate, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
@ -20,7 +20,6 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
@ -46,9 +45,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { auditSectionHref } from "./audit/audit-navigation";
import { routineDetailHref } from "../components/RoutineContextualSidebar";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
import type { FolderListItem } from "@paperclipai/shared";
import { Tabs } from "@/components/ui/tabs";
import {
AllUnfiledBanner,
BulkBar,
@ -83,8 +86,8 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
element.style.height = `${element.scrollHeight}px`;
}
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
type RoutinesTab = "routines" | "runs";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
@ -284,28 +287,20 @@ export function sortRoutines(
});
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
function RoutineSectionHeader({
label,
count,
isOpen,
isOpen: _isOpen,
}: {
label: string;
count: number;
isOpen: boolean;
}) {
return (
<div
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
isOpen ? " mb-1" : ""
}`}
>
<div className="flex items-center gap-2 px-2 py-1.5">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
<span data-routine-section-label className="text-sm font-medium">
{label}
</span>
</CollapsibleTrigger>
@ -339,7 +334,9 @@ export function Routines() {
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const legacyRunsRequested = searchParams.get("tab") === "runs";
const activeTab: RoutinesTab = !streamlinedUiEnabled && legacyRunsRequested ? "runs" : "routines";
const [draft, setDraft] = useState<{
title: string;
description: string;
@ -383,7 +380,7 @@ export function Routines() {
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
enabled: !!selectedCompanyId && activeTab === "routines",
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@ -403,15 +400,14 @@ export function Routines() {
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
enabled: !!selectedCompanyId && activeTab === "runs",
enabled: !!selectedCompanyId && !streamlinedUiEnabled && activeTab === "runs",
});
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId && activeTab === "runs",
// Event-sourced via LiveUpdatesProvider (GitHub issue 9627); no interval poll needed.
enabled: !!selectedCompanyId && !streamlinedUiEnabled && activeTab === "runs",
refetchInterval: false,
leaderOnly: true,
});
@ -422,7 +418,6 @@ export function Routines() {
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]);
@ -460,7 +455,7 @@ export function Routines() {
: "Draft saved. Add a default agent before enabling automation.",
tone: "success",
});
navigate(`/routines/${routine.id}?tab=triggers`);
navigate(routineDetailHref(routine.id, "triggers"));
},
});
const createFolder = useMutation({
@ -554,14 +549,6 @@ export function Routines() {
});
},
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] });
},
});
const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
onMutate: ({ id }) => {
@ -655,11 +642,33 @@ export function Routines() {
() => new Map((routineFolders?.folders ?? []).map((folder) => [folder.id, folder])),
[routineFolders],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, routineExecutionIssues), [liveRuns, routineExecutionIssues]);
const visibleRoutines = useMemo(
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
);
const liveIssueIds = useMemo(
() => collectLiveIssueIds(liveRuns, routineExecutionIssues),
[liveRuns, routineExecutionIssues],
);
const recentRunsIssueLinkState = useMemo(
() => createIssueDetailLocationState("Recent Runs", "/routines?tab=runs", "issues"),
[],
);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
});
},
});
function handleLegacyTabChange(tab: string) {
const nextTab: RoutinesTab = tab === "runs" ? "runs" : "routines";
startTransition(() => {
navigate(nextTab === "runs" ? "/routines?tab=runs" : "/routines");
});
}
const folderFilteredRoutines = useMemo(() => {
if (routineViewState.groupBy !== "folder") return visibleRoutines;
if (folderSelection === "all") return visibleRoutines;
@ -694,20 +703,11 @@ export function Routines() {
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById, folderById),
[agentById, folderById, projectById, routineViewState.groupBy, sortedRoutines],
);
const recentRunsIssueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Recent Runs",
buildRoutinesTabHref("runs"),
"issues",
),
[],
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
const showFolderRail = activeTab === "routines" && routineViewState.groupBy === "folder" && hasRoutineFolders;
const showFolderRail = routineViewState.groupBy === "folder" && hasRoutineFolders;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
@ -717,13 +717,6 @@ export function Routines() {
});
}
function handleTabChange(tab: string) {
const nextTab = tab === "runs" ? "runs" : "routines";
startTransition(() => {
navigate(buildRoutinesTabHref(nextTab));
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
@ -799,43 +792,97 @@ export function Routines() {
return <EmptyState icon={Repeat} message="Select an organization to view routines." />;
}
if (streamlinedUiEnabled && legacyRunsRequested) {
return <Navigate to={auditSectionHref("runs", {})} replace />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (!streamlinedUiEnabled && activeTab === "runs") {
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Routines</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
<Tabs value={activeTab} onValueChange={handleLegacyTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleLegacyTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
</Tabs>
<IssuesList
issues={routineExecutionIssues ?? []}
isLoading={recentRunsLoading}
error={recentRunsError as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:routine-recent-runs-view"
issueLinkState={recentRunsIssueLinkState}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Routines
</h1>
<h1 className="text-xl font-bold">Routines</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
<div className="flex items-center gap-2">
{streamlinedUiEnabled ? (
<Button variant="outline" asChild>
<Link to={auditSectionHref("runs", {})}>View all runs</Link>
</Button>
) : null}
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
<TabsContent value="routines" className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
</p>
<div className="flex items-center gap-1">
{!streamlinedUiEnabled ? (
<Tabs value={activeTab} onValueChange={handleLegacyTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleLegacyTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
</Tabs>
) : null}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
</p>
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
@ -919,33 +966,19 @@ export function Routines() {
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
</div>
{routineViewState.groupBy === "folder" ? (
<div className="md:hidden">
<FolderChip
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</TabsContent>
<TabsContent value="runs">
<IssuesList
issues={routineExecutionIssues ?? []}
isLoading={recentRunsLoading}
error={recentRunsError as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:routine-recent-runs-view"
issueLinkState={recentRunsIssueLinkState}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
</TabsContent>
</Tabs>
</div>
{routineViewState.groupBy === "folder" ? (
<div className="md:hidden">
<FolderChip
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</div>
<Dialog
open={composerOpen}
@ -1230,8 +1263,7 @@ export function Routines() {
</Card>
) : null}
{activeTab === "routines" ? (
<div className={cn(showFolderRail && "flex gap-4")}>
<div className={cn(showFolderRail && "flex gap-4")}>
{showFolderRail ? (
<FolderRail
result={railFolderResult}
@ -1382,7 +1414,6 @@ export function Routines() {
)}
</div>
</div>
) : null}
<FolderFormDialog
open={folderDialogOpen}

View File

@ -11,6 +11,7 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockWorkTimelineApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockLocation = vi.hoisted(() => ({ search: "" }));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
@ -25,7 +26,7 @@ vi.mock("@/api/workTimeline", () => ({
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/PAP/timeline" }),
useLocation: () => ({ pathname: "/PAP/timeline", search: mockLocation.search }),
}));
vi.mock("@/components/RequestCollapsedSidebar", () => ({
@ -130,6 +131,7 @@ describe("Timeline", () => {
defaultOptions: { queries: { retry: false } },
});
mockWorkTimelineApi.get.mockResolvedValue(emptyTimeline);
mockLocation.search = "";
});
afterEach(() => {
@ -156,6 +158,27 @@ describe("Timeline", () => {
expect(container.querySelector('[data-testid="request-collapsed-sidebar"]')).not.toBeNull();
});
it("applies project scope from the project Timeline control", async () => {
mockLocation.search = "?projectId=11111111-1111-4111-8111-111111111111";
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Project Timeline");
expect(mockWorkTimelineApi.get).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ projectId: "11111111-1111-4111-8111-111111111111" }),
expect.anything(),
);
});
it("renders range controls plus icon zoom controls without the user lens selector or visible-duration readout", async () => {
root = createRoot(container);

View File

@ -30,6 +30,8 @@ import {
} from "@/components/timeline/WorkTimelineChart";
import { formatDuration, TIMELINE_COLORS } from "@/lib/timeline/layout";
import { cn } from "@/lib/utils";
import { useLocation } from "@/lib/router";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
type RangePreset = "today" | "7d" | "30d" | "custom";
const TIMELINE_PAGE_LIMIT = 500;
@ -302,9 +304,15 @@ function TimelineSummaryStats({
);
}
export function Timeline() {
export function Timeline({ embedded = false }: { embedded?: boolean } = {}) {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const scopedProjectId = useMemo(
() => streamlinedUiEnabled ? new URLSearchParams(location.search).get("projectId") || undefined : undefined,
[location.search, streamlinedUiEnabled],
);
const [zoom, setZoom] = useState<ZoomLevel>("day");
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
const zoomTouched = useRef(false);
@ -313,18 +321,20 @@ export function Timeline() {
const [visibleWindow, setVisibleWindow] = useState<VisibleTimelineWindow | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Timeline" }]);
}, [setBreadcrumbs]);
if (!embedded) {
setBreadcrumbs([{ label: scopedProjectId ? "Project Timeline" : "Timeline" }]);
}
}, [embedded, scopedProjectId, setBreadcrumbs]);
const dateRangeError = rangeError(dateRange);
const params: WorkTimelineParams | null = useMemo(() => {
const window = rangeWindow(dateRange);
if (!window) return null;
return window;
}, [dateRange]);
return scopedProjectId ? { ...window, projectId: scopedProjectId } : window;
}, [dateRange, scopedProjectId]);
const { data, isLoading, error } = useQuery({
queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate],
queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate, scopedProjectId ?? null],
queryFn: ({ signal }) => loadTimelineWindow(selectedCompanyId!, params!, signal),
enabled: !!selectedCompanyId && !!params,
});
@ -351,7 +361,7 @@ export function Timeline() {
if (!selectedCompanyId) {
return (
<>
<RequestCollapsedSidebar />
{!embedded && <RequestCollapsedSidebar />}
<EmptyState icon={GanttChartSquare} message="Select an organization to view its work timeline." />
</>
);
@ -360,7 +370,9 @@ export function Timeline() {
const header = (
<div className="flex items-center gap-2">
<GanttChartSquare className="h-6 w-6 text-muted-foreground" />
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
<h1 className="text-3xl font-semibold tracking-tight">
{scopedProjectId ? "Project Timeline" : "Work Timeline"}
</h1>
</div>
);
@ -463,7 +475,7 @@ export function Timeline() {
return (
<div className="space-y-6">
<RequestCollapsedSidebar />
{!embedded && <RequestCollapsedSidebar />}
{header}
{toolbar}
@ -491,7 +503,10 @@ export function Timeline() {
{data && !isLoading && !dateRangeError && (
data.spans.length === 0 ? (
<div className="space-y-3">
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
<EmptyState
icon={GanttChartSquare}
message={scopedProjectId ? "No project activity in this window." : "No activity in this window."}
/>
<div className="flex flex-wrap items-center justify-end gap-3">
{rangeControls}
</div>

View File

@ -0,0 +1,655 @@
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Download, ScrollText, ShieldAlert } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Identity } from "@/components/Identity";
import { AgentIcon } from "@/components/AgentIconPicker";
import { cn, relativeTime } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import { formatActivityVerb } from "@/lib/activity-format";
import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members";
import { auditApi, type AuditActionRecord, type AuditActionFilters } from "@/api/audit";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { useToastActions } from "@/context/ToastContext";
const PAGE_SIZE = 50;
const ALL = "__all";
/** Action-domain prefixes offered in the filter (server does a prefix match). */
const ACTION_DOMAINS: { value: string; label: string }[] = [
{ value: ALL, label: "All actions" },
{ value: "issue.", label: "Tasks" },
{ value: "agent.", label: "Agents" },
{ value: "heartbeat.", label: "Runs" },
{ value: "approval.", label: "Approvals" },
{ value: "project.", label: "Projects" },
{ value: "goal.", label: "Goals" },
{ value: "tool_gateway.", label: "Tools" },
{ value: "cost.", label: "Costs" },
{ value: "company.", label: "Company" },
];
/** Entity types offered in the filter (server does an exact match). */
const ENTITY_TYPES: { value: string; label: string }[] = [
{ value: ALL, label: "All entities" },
{ value: "issue", label: "Task" },
{ value: "agent", label: "Agent" },
{ value: "project", label: "Project" },
{ value: "goal", label: "Goal" },
{ value: "company", label: "Company" },
];
/**
* Which actors the feed covers. `all` is the shared company activity view
* (people, agents, and the system); `agents` is the privileged agent-action
* audit that carries responsible-person and run attribution.
*/
export type AuditFeedMode = "all" | "agents";
export interface AuditFeedProps {
companyId: string;
/**
* When set, the feed is pinned to a single agent (per-agent Audit tab) the
* agent filter is hidden and every query/export carries this agentId.
*/
lockedAgentId?: string;
/** Hide the section header/description (the AgentDetail tab supplies its own chrome). */
hideHeader?: boolean;
/**
* Controlled feed mode. Supplying `onModeChange` turns on the mode toggle for
* callers that hold `audit:view_agent_actions`; without it the feed stays in
* `mode` (or the all-actors default). Ignored when `lockedAgentId` is set.
*/
mode?: AuditFeedMode;
onModeChange?: (mode: AuditFeedMode) => void;
}
function toStartIso(value: string): string | undefined {
if (!value) return undefined;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function toEndIso(value: string): string | undefined {
if (!value) return undefined;
const date = new Date(`${value}T23:59:59.999Z`);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
/** Actor avatar + name — agents render their icon glyph, humans their avatar. */
function AuditActor({
record,
agentMap,
userProfileMap,
}: {
record: AuditActionRecord;
agentMap: Map<string, Agent>;
userProfileMap: Map<string, CompanyUserProfile>;
}) {
// Agent names are company-readable through the same authorization-filtered
// directory used by this page. The basic audit tier strips privileged
// attribution (`agentId`) but retains the acting principal (`actorId`), so
// use that principal to avoid presenting a trivially joinable identity as
// an anonymous "Agent" in the UI.
const actorAgentId = record.agentId
?? (record.actorType === "agent" ? record.actorId : null);
const agent = actorAgentId ? agentMap.get(actorAgentId) : null;
if (agent) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5" title={agent.name}>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-3 w-3" />
</span>
<span className="truncate font-medium text-foreground">{agent.name}</span>
</span>
);
}
if (record.actorType === "user" && record.actorId) {
const profile = userProfileMap.get(record.actorId);
return (
<Identity
name={profile?.label ?? "User"}
avatarUrl={profile?.image ?? null}
size="sm"
className="font-medium text-foreground"
/>
);
}
// Fall back to the actor *type*, never a blanket "System". This still covers
// deleted or authorization-filtered agents that are absent from the directory.
const label =
record.actorType === "plugin"
? "Plugin"
: record.actorType === "agent"
? "Agent"
: record.actorType === "user"
? "User"
: "System";
return <Identity name={label} size="sm" className="font-medium text-foreground" />;
}
/**
* The clickable entity node inside the humanized sentence. The verb from
* `formatActivityVerb` already encodes the relationship ("commented on",
* "created document for", ) and expects the issue reference to follow it, so
* this renders the task link (or a document/plain fallback) never a phrase
* that would duplicate the verb.
*/
function AuditEntityNode({ record }: { record: AuditActionRecord }) {
const { issue, document } = record.entity;
const issueRef = issue?.identifier ?? issue?.id ?? null;
if (issueRef) {
return (
<Link to={`/issues/${issueRef}`} className="font-medium text-primary hover:underline">
{issue?.identifier ? `${issue.identifier}${issue.title ? ` · ${issue.title}` : ""}` : "the task"}
</Link>
);
}
if (document) {
return <span className="font-medium text-foreground">{document.key}</span>;
}
// Non-linkable entities (company, agent, goal, …) — show a plain descriptor.
return <span className="text-muted-foreground">{record.entityType}</span>;
}
function AuditRow({
record,
agentMap,
userProfileMap,
}: {
record: AuditActionRecord;
agentMap: Map<string, Agent>;
userProfileMap: Map<string, CompanyUserProfile>;
}) {
const verb = formatActivityVerb(record.action, record.details, { agentMap, userProfileMap });
const responsible = record.responsibleUserId ? userProfileMap.get(record.responsibleUserId) : null;
// Suppress the "on behalf of" chip when the human actor *is* the responsible user.
const showOnBehalf = Boolean(
record.responsibleUserId
&& !(record.actorType === "user" && record.actorId === record.responsibleUserId),
);
const responsibleLabel = responsible?.label ?? (record.responsibleUserId ? "a user" : null);
const excerpt = record.entity.comment?.excerpt?.trim();
// Show the document key only when it isn't already the linked entity node.
const documentKey = record.entity.issue && record.entity.document ? record.entity.document.key : null;
return (
<li className="px-4 py-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-foreground">
<AuditActor record={record} agentMap={agentMap} userProfileMap={userProfileMap} />
<span className="text-muted-foreground">{verb}</span>
<AuditEntityNode record={record} />
</div>
{excerpt ? (
<p className="line-clamp-2 border-l-2 border-border pl-2 text-muted-foreground">
{excerpt}
</p>
) : null}
{documentKey ? (
<p className="text-xs text-muted-foreground">
Document <span className="font-mono text-(length:--text-micro)">{documentKey}</span>
</p>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{showOnBehalf && responsibleLabel ? (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5">
on behalf of {responsibleLabel}
</span>
) : null}
{record.runId && record.agentId ? (
<Link
to={`/agents/${record.agentId}/runs/${record.runId}`}
className="text-primary hover:underline"
>
View run
</Link>
) : null}
<span className="font-mono text-(length:--text-micro) opacity-70">{record.action}</span>
</div>
</div>
<time
className="shrink-0 whitespace-nowrap text-xs text-muted-foreground"
dateTime={record.createdAt}
title={new Date(record.createdAt).toLocaleString()}
>
{relativeTime(record.createdAt)}
</time>
</div>
</li>
);
}
/** The permission-denied / upsell state shown when the caller lacks the grant. */
function AuditUpsell() {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
<ShieldAlert className="h-10 w-10 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium text-foreground">Agent audit is a Paperclip Enterprise view</p>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
The agent audit log gives you a searchable, exportable record of everything your agents
did every comment, task change, approval, and run with the responsible person for
each action. Ask an administrator to grant you the{" "}
<span className="font-mono text-(length:--text-micro)">audit:view_agent_actions</span>{" "}
permission to view it.
</p>
</div>
</CardContent>
</Card>
);
}
export function AuditFeed({
companyId,
lockedAgentId,
hideHeader,
mode,
onModeChange,
}: AuditFeedProps) {
const { pushToast } = useToastActions();
const [agent, setAgent] = useState<string>(ALL);
const [responsibleUser, setResponsibleUser] = useState<string>(ALL);
const [actionDomain, setActionDomain] = useState<string>(ALL);
const [entityType, setEntityType] = useState<string>(ALL);
const [dateFrom, setDateFrom] = useState<string>("");
const [dateTo, setDateTo] = useState<string>("");
const [exporting, setExporting] = useState(false);
const [downgradeRecoveryAttempted, setDowngradeRecoveryAttempted] = useState(false);
const agents = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
});
const userDirectory = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId),
queryFn: () => accessApi.listUserDirectory(companyId),
retry: false,
});
const agentMap = useMemo(
() => new Map((agents.data ?? []).map((a) => [a.id, a])),
[agents.data],
);
const userProfileMap = useMemo(
() => buildCompanyUserProfileMap(userDirectory.data?.users),
[userDirectory.data],
);
// The per-agent tab keeps the legacy privileged scope because it always
// carries an attribution filter and must not silently downgrade to the basic
// tier. Everywhere else the mode picks the scope, defaulting to all actors.
const resolvedMode: AuditFeedMode = lockedAgentId ? "agents" : mode ?? "all";
const filters: AuditActionFilters = {
actorScope: resolvedMode,
agentId: lockedAgentId ?? (agent === ALL ? undefined : agent),
responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser,
action: actionDomain === ALL ? undefined : actionDomain,
entityType: entityType === ALL ? undefined : entityType,
from: toStartIso(dateFrom),
to: toEndIso(dateTo),
};
const hasActiveFilters = Boolean(
(!lockedAgentId && agent !== ALL)
|| responsibleUser !== ALL
|| actionDomain !== ALL
|| entityType !== ALL
|| dateFrom
|| dateTo,
);
const feed = useInfiniteQuery({
queryKey: queryKeys.audit.agentActions(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
entityType: filters.entityType,
from: filters.from,
to: filters.to,
}),
queryFn: ({ pageParam }) =>
auditApi.listAgentActions(companyId, { ...filters, limit: PAGE_SIZE, cursor: pageParam ?? undefined }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
retry: (count, error) => !(error instanceof ApiError && error.status === 403) && count < 2,
});
const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403;
const hasBasicPage = feed.data?.pages.some((page) => page.accessTier === "basic") ?? false;
const hasFullPage = feed.data?.pages.some((page) => page.accessTier === "full") ?? false;
// Once the server answers at the basic tier the caller has lost the permission
// that produced the privileged attribution on the pages already in the cache.
// Drop those pages rather than rendering revoked "on behalf of" attribution
// next to stripped rows — the recovery refetch below may never clear them.
const items = useMemo(() => {
const pages = feed.data?.pages ?? [];
const visible = hasBasicPage ? pages.filter((page) => page.accessTier !== "full") : pages;
return visible.flatMap((page) => page.items);
}, [feed.data, hasBasicPage]);
// Access may be revoked between cursor requests. Treat the least-privileged
// page as authoritative until every cached page has been fetched again.
const accessTier = hasBasicPage ? "basic" : feed.data?.pages[0]?.accessTier;
const hasMixedAccessTiers = hasBasicPage && hasFullPage;
const canUseAdvancedControls = lockedAgentId
? true
: accessTier === "full";
// The recovery refetch below gets one shot. If it does not clear the mixed
// pages — it errored, or it somehow came back mixed again — the cache keeps
// them, so `hasMixedAccessTiers` would stay true forever. Only call the feed
// "recovering" while that attempt is outstanding; once it has settled, fall
// through to normal rendering. Otherwise the banner permanently hides the
// error state and its "Try again" button, with no way off the page. Falling
// through is safe because `items` already excludes the privileged pages, so
// an unrecovered cache renders as a plain basic-tier feed.
const downgradeRecoveryExhausted = Boolean(
hasMixedAccessTiers && downgradeRecoveryAttempted && !feed.isFetching,
);
const recoveringFromAccessDowngrade = Boolean(
!lockedAgentId
&& !downgradeRecoveryExhausted
&& ((permissionDenied && hasActiveFilters) || hasMixedAccessTiers),
);
// A reader without `audit:view_agent_actions` can still land on the
// agent-actions mode through an old `/audit` deep link. Drop them into the
// shared all-activity feed instead of blocking the whole page with the upsell.
const fallingBackToAllActivity = Boolean(
permissionDenied && !lockedAgentId && resolvedMode === "agents" && onModeChange,
);
// The privileged mode is only offered to callers the server already answered
// at the full tier — everyone else just gets the basic all-activity feed.
const showModeToggle = Boolean(
!lockedAgentId
&& onModeChange
&& !fallingBackToAllActivity
&& (resolvedMode === "agents" || accessTier === "full"),
);
useEffect(() => {
if (fallingBackToAllActivity) onModeChange?.("all");
}, [fallingBackToAllActivity, onModeChange]);
useEffect(() => {
if (!lockedAgentId && (accessTier === "basic" || recoveringFromAccessDowngrade)) {
setAgent(ALL);
setResponsibleUser(ALL);
setActionDomain(ALL);
setEntityType(ALL);
setDateFrom("");
setDateTo("");
}
}, [accessTier, hasMixedAccessTiers, lockedAgentId, recoveringFromAccessDowngrade]);
// Recover from a mid-pagination downgrade with exactly one refetch. `feed`
// gets a new identity on every render, so an unguarded refetch here re-fires
// on each render and hammers the endpoint while the tiers stay mixed.
useEffect(() => {
if (!hasMixedAccessTiers) {
if (downgradeRecoveryAttempted) setDowngradeRecoveryAttempted(false);
return;
}
if (downgradeRecoveryAttempted) return;
setDowngradeRecoveryAttempted(true);
void feed.refetch();
}, [downgradeRecoveryAttempted, feed, hasMixedAccessTiers]);
const clearFilters = () => {
setAgent(ALL);
setResponsibleUser(ALL);
setActionDomain(ALL);
setEntityType(ALL);
setDateFrom("");
setDateTo("");
};
const handleExport = async () => {
setExporting(true);
try {
const blob = await auditApi.exportAgentActionsCsv(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
entityType: filters.entityType,
from: filters.from,
to: filters.to,
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${resolvedMode === "agents" ? "agent-audit" : "activity"}-${companyId}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
// Browsers may read blob URLs lazily after click(), so keep the URL alive
// long enough for the download to start.
window.setTimeout(() => URL.revokeObjectURL(url), 5_000);
pushToast({ title: "Audit exported", body: "Your CSV download has started.", tone: "success" });
} catch (error) {
pushToast({
title: "Export failed",
body: error instanceof Error ? error.message : "Could not export the audit log.",
tone: "error",
});
} finally {
setExporting(false);
}
};
if (permissionDenied && !recoveringFromAccessDowngrade && !fallingBackToAllActivity) {
return <AuditUpsell />;
}
return (
<div className="space-y-4">
{!hideHeader ? (
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Activity</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{resolvedMode === "agents"
? "Every recorded agent action, newest first — with the responsible person and run behind each one."
: "Everything happening in your company, newest first — people, agents, and the system. Each line is one recorded action."}
</p>
</div>
</div>
) : null}
{showModeToggle ? (
<Tabs value={resolvedMode} onValueChange={(value) => onModeChange?.(value as AuditFeedMode)}>
<TabsList aria-label="Activity scope">
<TabsTrigger value="all">All activity</TabsTrigger>
<TabsTrigger value="agents">Agent actions</TabsTrigger>
</TabsList>
</Tabs>
) : null}
{canUseAdvancedControls ? (
<div className="flex flex-wrap items-center gap-2">
{!lockedAgentId ? (
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Agent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{(agents.data ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
{/* Wide enough for "All responsible users" — w-44 truncated it. */}
<SelectTrigger className="w-52">
<SelectValue placeholder="Responsible user" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All responsible users</SelectItem>
{(userDirectory.data?.users ?? []).map((u) => (
<SelectItem key={u.principalId} value={u.principalId}>
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={actionDomain} onValueChange={setActionDomain}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
{ACTION_DOMAINS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Entity" />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
aria-label="From date"
value={dateFrom}
max={dateTo || undefined}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
/>
<Input
type="date"
aria-label="To date"
value={dateTo}
min={dateFrom || undefined}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
/>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={handleExport}
disabled={exporting || feed.isLoading || items.length === 0}
>
<Download className="mr-1.5 h-4 w-4" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
</div>
) : null}
{recoveringFromAccessDowngrade || fallingBackToAllActivity ? (
<Card>
<CardContent className="py-14 text-center text-sm text-muted-foreground">
Refreshing audit access
</CardContent>
</Card>
) : feed.isLoading ? (
<Card>
<CardContent className="py-14 text-center text-sm text-muted-foreground">Loading</CardContent>
</Card>
) : feed.error ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
<p className="text-sm text-muted-foreground">
{feed.error instanceof Error ? feed.error.message : "Failed to load the audit log."}
</p>
<Button variant="outline" size="sm" onClick={() => feed.refetch()}>
Try again
</Button>
</CardContent>
</Card>
) : items.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
<ScrollText className="h-10 w-10 text-muted-foreground/40" />
<div>
<p className="text-sm font-medium text-foreground">
{hasActiveFilters ? "No actions match these filters" : "Nothing here yet"}
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{hasActiveFilters
? "Try a wider date range or different filters."
: resolvedMode === "agents"
? "As soon as your agents start doing things, their actions show up here."
: "As soon as anyone in your company does something, it shows up here."}
</p>
</div>
{hasActiveFilters ? (
<Button variant="outline" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="px-0 py-0">
<ul className={cn("divide-y divide-border")}>
{items.map((record) => (
<AuditRow
key={record.id}
record={record}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
))}
</ul>
</CardContent>
</Card>
)}
{feed.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => feed.fetchNextPage()}
disabled={feed.isFetchingNextPage}
>
{feed.isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
</div>
) : null}
<p className="text-xs text-muted-foreground">
Recorded by Paperclip entries can't be edited. Sensitive values are never stored.
</p>
</div>
);
}

View File

@ -109,6 +109,8 @@ describe("AuditFeed", () => {
props: {
companyId?: string;
lockedAgentId?: string;
lockedRunId?: string;
lockedEntity?: { type: string; id: string; label?: string };
mode?: "all" | "agents";
onModeChange?: (mode: "all" | "agents") => void;
} = {},
@ -121,6 +123,8 @@ describe("AuditFeed", () => {
<AuditFeed
companyId={props.companyId ?? "company-1"}
lockedAgentId={props.lockedAgentId}
lockedRunId={props.lockedRunId}
lockedEntity={props.lockedEntity}
mode={props.mode}
onModeChange={props.onModeChange}
/>
@ -218,12 +222,16 @@ describe("AuditFeed", () => {
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("All responsible users");
expect(container.textContent).not.toContain("Export CSV");
expect(container.textContent).toContain("Action");
expect(container.textContent).toContain("Entity");
expect(container.textContent).toContain("From");
expect(container.textContent).toContain("To");
});
it("clears privileged filters and recovers the basic feed after an access downgrade", async () => {
let permissionRevoked = false;
listAgentActionsMock.mockImplementation((_companyId: string, filters: { from?: string }) => {
if (permissionRevoked && filters.from) {
listAgentActionsMock.mockImplementation((_companyId: string, filters: { agentId?: string }) => {
if (permissionRevoked && filters.agentId) {
return Promise.reject(
new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }),
);
@ -236,35 +244,27 @@ describe("AuditFeed", () => {
});
await render();
const fromDate = container.querySelector<HTMLInputElement>('input[aria-label="From date"]');
expect(fromDate).toBeTruthy();
const setInputValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
expect(setInputValue).toBeTruthy();
permissionRevoked = true;
await clickButton("All agents");
const fableOption = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => option.textContent?.trim() === "Fable");
expect(fableOption).toBeTruthy();
await act(async () => {
permissionRevoked = true;
setInputValue!.call(fromDate, "2026-08-01");
fromDate!.dispatchEvent(new Event("input", { bubbles: true }));
fableOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// The recovery settles across the 403, the filter reset, and the basic-tier
// refetch. The privileged filter fires first (from set), then the recovery
// refetch clears it (from undefined). Wait for that recovery refetch and the
// dropped filter chrome instead of a fixed flush count.
await waitForCondition(
() =>
listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)
&& listAgentActionsMock.mock.calls.at(-1)?.[1]?.from === undefined
listAgentActionsMock.mock.calls.some(([, filters]) => filters.agentId)
&& listAgentActionsMock.mock.calls.at(-1)?.[1]?.agentId === undefined
&& !container.textContent?.includes("All agents"),
"the basic feed after the access downgrade",
20_000,
);
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true);
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.agentId)).toBe(true);
await vi.waitFor(() => {
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", from: undefined }),
expect.objectContaining({ actorScope: "all", agentId: undefined }),
);
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("Paperclip Enterprise view");
@ -431,13 +431,13 @@ describe("AuditFeed", () => {
});
await flushReact();
expect(container.textContent).toContain("All activity");
expect(container.textContent).toContain("Agent actions");
expect(container.textContent).toContain("Activity");
expect(container.textContent).toContain("Agent Actions");
expect(listAgentActionsMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ actorScope: "all" }),
);
await clickTab("Agent actions");
await clickTab("Agent Actions");
await flushReact();
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
@ -479,8 +479,9 @@ describe("AuditFeed", () => {
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "basic" });
await render({ mode: "all", onModeChange: vi.fn() });
expect(container.querySelector('[role="tab"]')).toBeFalsy();
expect(container.textContent).not.toContain("Agent actions");
const tabs = Array.from(container.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(["Activity", "Agent Actions"]);
expect(tabs.find((tab) => tab.textContent?.trim() === "Agent Actions")?.disabled).toBe(true);
// The basic feed itself still renders.
expect(container.textContent).toContain("commented on");
});
@ -506,6 +507,33 @@ describe("AuditFeed", () => {
expect(container.textContent).toContain("Paperclip Enterprise view");
});
it("renders a flat activity list with clearly labeled filters", async () => {
await render();
const list = container.querySelector('ul[aria-label="Audit activity"]');
expect(list).toBeTruthy();
expect(list?.closest('[data-slot="card"]')).toBeFalsy();
expect(container.textContent).toContain("Agent");
expect(container.textContent).toContain("Responsible user");
expect(container.textContent).toContain("Action");
expect(container.textContent).toContain("Entity");
});
it("pins run and entity scopes into distinct audit requests", async () => {
await render({ lockedRunId: "run-42" });
expect(listAgentActionsMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ actorScope: "agents", runId: "run-42" }),
);
expect(container.textContent).toContain("Scoped to run run-42");
flushSync(() => root.unmount());
await render({ lockedEntity: { type: "routine", id: "routine-7", label: "Nightly triage" } });
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ entityType: "routine", entityId: "routine-7" }),
);
expect(container.textContent).toContain("Scoped to Nightly triage");
});
it("only offers action domains present in the agent-action feed", async () => {
await render();

View File

@ -48,6 +48,8 @@ const ENTITY_TYPES: { value: string; label: string }[] = [
{ value: ALL, label: "All entities" },
{ value: "issue", label: "Task" },
{ value: "agent", label: "Agent" },
{ value: "heartbeat_run", label: "Run" },
{ value: "routine", label: "Routine" },
{ value: "project", label: "Project" },
{ value: "goal", label: "Goal" },
{ value: "company", label: "Organization" },
@ -67,6 +69,10 @@ export interface AuditFeedProps {
* agent filter is hidden and every query/export carries this agentId.
*/
lockedAgentId?: string;
/** Pin the feed to one run while preserving the existing run-detail links. */
lockedRunId?: string;
/** Pin the feed to an entity such as a routine. */
lockedEntity?: { type: string; id: string; label?: string };
/** Hide the section header/description (the AgentDetail tab supplies its own chrome). */
hideHeader?: boolean;
/**
@ -260,6 +266,8 @@ function AuditUpsell() {
export function AuditFeed({
companyId,
lockedAgentId,
lockedRunId,
lockedEntity,
hideHeader,
mode,
onModeChange,
@ -296,14 +304,17 @@ export function AuditFeed({
// The per-agent tab keeps the legacy privileged scope because it always
// carries an attribution filter and must not silently downgrade to the basic
// tier. Everywhere else the mode picks the scope, defaulting to all actors.
const resolvedMode: AuditFeedMode = lockedAgentId ? "agents" : mode ?? "all";
const resolvedMode: AuditFeedMode = lockedAgentId || lockedRunId ? "agents" : mode ?? "all";
const hasLockedScope = Boolean(lockedAgentId || lockedRunId || lockedEntity);
const filters: AuditActionFilters = {
actorScope: resolvedMode,
agentId: lockedAgentId ?? (agent === ALL ? undefined : agent),
runId: lockedRunId,
responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser,
action: actionDomain === ALL ? undefined : actionDomain,
entityType: entityType === ALL ? undefined : entityType,
entityType: lockedEntity?.type ?? (entityType === ALL ? undefined : entityType),
entityId: lockedEntity?.id,
from: toStartIso(dateFrom),
to: toEndIso(dateTo),
};
@ -316,14 +327,19 @@ export function AuditFeed({
|| dateFrom
|| dateTo,
);
const hasPrivilegedFilters = Boolean(
!hasLockedScope && (agent !== ALL || responsibleUser !== ALL),
);
const feed = useInfiniteQuery({
queryKey: queryKeys.audit.agentActions(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
runId: filters.runId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
entityType: filters.entityType,
entityId: filters.entityId,
from: filters.from,
to: filters.to,
}),
@ -351,7 +367,7 @@ export function AuditFeed({
// page as authoritative until every cached page has been fetched again.
const accessTier = hasBasicPage ? "basic" : feed.data?.pages[0]?.accessTier;
const hasMixedAccessTiers = hasBasicPage && hasFullPage;
const canUseAdvancedControls = lockedAgentId
const canUseAdvancedControls = lockedAgentId || lockedRunId
? true
: accessTier === "full";
// The recovery refetch below gets one shot. If it does not clear the mixed
@ -366,23 +382,22 @@ export function AuditFeed({
hasMixedAccessTiers && downgradeRecoveryAttempted && !feed.isFetching,
);
const recoveringFromAccessDowngrade = Boolean(
!lockedAgentId
!hasLockedScope
&& !downgradeRecoveryExhausted
&& ((permissionDenied && hasActiveFilters) || hasMixedAccessTiers),
&& ((permissionDenied && hasPrivilegedFilters) || hasMixedAccessTiers),
);
// A reader without `audit:view_agent_actions` can still land on the
// agent-actions mode through an old `/audit` deep link. Drop them into the
// shared all-activity feed instead of blocking the whole page with the upsell.
const fallingBackToAllActivity = Boolean(
permissionDenied && !lockedAgentId && resolvedMode === "agents" && onModeChange,
permissionDenied && !hasLockedScope && resolvedMode === "agents" && onModeChange,
);
// The privileged mode is only offered to callers the server already answered
// at the full tier — everyone else just gets the basic all-activity feed.
// Keep both modes explicit in the Audit IA. Basic readers can see that Agent
// Actions exists, but cannot switch into the privileged scope.
const showModeToggle = Boolean(
!lockedAgentId
!hasLockedScope
&& onModeChange
&& !fallingBackToAllActivity
&& (resolvedMode === "agents" || accessTier === "full"),
);
useEffect(() => {
@ -390,15 +405,11 @@ export function AuditFeed({
}, [fallingBackToAllActivity, onModeChange]);
useEffect(() => {
if (!lockedAgentId && (accessTier === "basic" || recoveringFromAccessDowngrade)) {
if (!hasLockedScope && (accessTier === "basic" || recoveringFromAccessDowngrade)) {
setAgent(ALL);
setResponsibleUser(ALL);
setActionDomain(ALL);
setEntityType(ALL);
setDateFrom("");
setDateTo("");
}
}, [accessTier, hasMixedAccessTiers, lockedAgentId, recoveringFromAccessDowngrade]);
}, [accessTier, hasLockedScope, recoveringFromAccessDowngrade]);
// Recover from a mid-pagination downgrade with exactly one refetch. `feed`
// gets a new identity on every render, so an unguarded refetch here re-fires
@ -428,9 +439,11 @@ export function AuditFeed({
const blob = await auditApi.exportAgentActionsCsv(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
runId: filters.runId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
entityType: filters.entityType,
entityId: filters.entityId,
from: filters.from,
to: filters.to,
});
@ -465,7 +478,7 @@ export function AuditFeed({
{!hideHeader ? (
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Activity</h1>
<h2 className="text-lg font-semibold text-foreground">Activity</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{resolvedMode === "agents"
? "Every recorded agent action, newest first — with the responsible person and run behind each one."
@ -478,15 +491,32 @@ export function AuditFeed({
{showModeToggle ? (
<Tabs value={resolvedMode} onValueChange={(value) => onModeChange?.(value as AuditFeedMode)}>
<TabsList aria-label="Activity scope">
<TabsTrigger value="all">All activity</TabsTrigger>
<TabsTrigger value="agents">Agent actions</TabsTrigger>
<TabsTrigger value="all">Activity</TabsTrigger>
<TabsTrigger
value="agents"
disabled={accessTier === "basic"}
title={accessTier === "basic" ? "Agent Actions requires audit access" : undefined}
>
Agent Actions
</TabsTrigger>
</TabsList>
</Tabs>
) : null}
{canUseAdvancedControls ? (
<div className="flex flex-wrap items-center gap-2">
{!lockedAgentId ? (
{hasLockedScope ? (
<div className="border-y border-border px-1 py-2 text-xs text-muted-foreground">
{lockedRunId
? `Scoped to run ${lockedRunId.slice(0, 8)}`
: lockedAgentId
? "Scoped to one agent"
: `Scoped to ${lockedEntity?.label ?? lockedEntity?.type ?? "entity"}`}
</div>
) : null}
<div className="flex flex-wrap items-end gap-3 border-y border-border py-3">
{canUseAdvancedControls && !lockedAgentId && !lockedRunId ? (
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Agent</span>
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Agent" />
@ -500,23 +530,31 @@ export function AuditFeed({
))}
</SelectContent>
</Select>
) : null}
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
{/* Wide enough for "All responsible users" — w-44 truncated it. */}
<SelectTrigger className="w-52">
<SelectValue placeholder="Responsible user" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All responsible users</SelectItem>
{(userDirectory.data?.users ?? []).map((u) => (
<SelectItem key={u.principalId} value={u.principalId}>
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
) : null}
{canUseAdvancedControls ? (
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Responsible user</span>
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
{/* Wide enough for "All responsible users" — w-44 truncated it. */}
<SelectTrigger className="w-52">
<SelectValue placeholder="Responsible user" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All responsible users</SelectItem>
{(userDirectory.data?.users ?? []).map((u) => (
<SelectItem key={u.principalId} value={u.principalId}>
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
) : null}
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Action</span>
<Select value={actionDomain} onValueChange={setActionDomain}>
<SelectTrigger className="w-36">
<SelectTrigger className="w-40">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
@ -527,18 +565,26 @@ export function AuditFeed({
))}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Entity" />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{!lockedEntity ? (
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Entity</span>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Entity" />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
) : null}
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>From</span>
<Input
type="date"
aria-label="From date"
@ -547,6 +593,9 @@ export function AuditFeed({
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
/>
</label>
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>To</span>
<Input
type="date"
aria-label="To date"
@ -555,11 +604,13 @@ export function AuditFeed({
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
/>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
</label>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
{canUseAdvancedControls ? (
<Button
variant="outline"
size="sm"
@ -570,8 +621,8 @@ export function AuditFeed({
<Download className="mr-1.5 h-4 w-4" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
</div>
) : null}
) : null}
</div>
{recoveringFromAccessDowngrade || fallingBackToAllActivity ? (
<Card>
@ -618,20 +669,18 @@ export function AuditFeed({
</CardContent>
</Card>
) : (
<Card>
<CardContent className="px-0 py-0">
<ul className={cn("divide-y divide-border")}>
{items.map((record) => (
<AuditRow
key={record.id}
record={record}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
))}
</ul>
</CardContent>
</Card>
<div className="border-y border-border">
<ul className={cn("divide-y divide-border")} aria-label="Audit activity">
{items.map((record) => (
<AuditRow
key={record.id}
record={record}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
))}
</ul>
</div>
)}
{feed.hasNextPage ? (

View File

@ -0,0 +1,170 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuditHub } from "./AuditHub";
const navigateMock = vi.hoisted(() => vi.fn());
const setSearchParamsMock = vi.hoisted(() => vi.fn());
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
let currentSearch = "";
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
useSearchParams: () => [new URLSearchParams(currentSearch), setSearchParamsMock],
}));
vi.mock("./AuditFeed", () => ({
AuditFeed: (props: Record<string, unknown>) => (
<div
data-testid="audit-feed"
data-mode={props.mode}
data-agent={props.lockedAgentId}
data-run={props.lockedRunId}
data-entity={JSON.stringify(props.lockedEntity ?? null)}
/>
),
}));
vi.mock("./AuditRuns", () => ({
AuditRuns: ({ companyId, routineId }: { companyId: string; routineId?: string }) => (
<div data-testid="audit-runs" data-company={companyId} data-routine={routineId} />
),
}));
vi.mock("./RoutineAuditActivity", () => ({
RoutineAuditActivity: ({ companyId, routineId }: { companyId: string; routineId: string }) => (
<div data-testid="routine-audit-activity" data-company={companyId} data-routine={routineId} />
),
}));
vi.mock("@/pages/Costs", () => ({
Costs: (props: Record<string, unknown>) => (
<div
data-testid="audit-costs"
data-initial-tab={props.initialTab}
data-lock-tab={String(props.lockTab ?? false)}
data-hide-budgets={String(props.hideBudgetsTab ?? false)}
/>
),
}));
vi.mock("@/pages/Timeline", () => ({
Timeline: ({ embedded }: { embedded?: boolean }) => (
<div data-testid="audit-timeline" data-embedded={String(embedded ?? false)} />
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("AuditHub", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
currentSearch = "";
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
function render(section: "activity" | "runs" | "costs" | "budgets" | "timeline") {
root = createRoot(container);
flushSync(() => root.render(<AuditHub section={section} />));
}
it("uses one clear section model and passes deep-link scopes to Activity", () => {
currentSearch = "mode=agents&agentId=agent-1&runId=run-1";
render("activity");
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(5);
expect(container.textContent).toContain("Activity");
expect(container.textContent).toContain("Runs");
expect(container.textContent).toContain("Costs");
expect(container.textContent).toContain("Budgets");
expect(container.textContent).toContain("Timeline");
const feed = container.querySelector<HTMLElement>('[data-testid="audit-feed"]');
expect(feed?.dataset.mode).toBe("agents");
expect(feed?.dataset.agent).toBe("agent-1");
expect(feed?.dataset.run).toBe("run-1");
expect(feed?.dataset.entity).toBe(JSON.stringify(null));
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Audit" }]);
});
it("uses routine-scoped activity instead of the privileged organization feed", () => {
currentSearch = "entityType=routine&entityId=routine-1";
render("activity");
expect(container.querySelector('[data-testid="audit-feed"]')).toBeNull();
const activity = container.querySelector<HTMLElement>('[data-testid="routine-audit-activity"]');
expect(activity?.dataset.company).toBe("company-1");
expect(activity?.dataset.routine).toBe("routine-1");
});
it("passes routine scope to the Runs section", () => {
currentSearch = "entityType=routine&entityId=routine-1";
render("runs");
const runs = container.querySelector<HTMLElement>('[data-testid="audit-runs"]');
expect(runs?.dataset.company).toBe("company-1");
expect(runs?.dataset.routine).toBe("routine-1");
});
it("renders Timeline as the section after Budgets", () => {
render("timeline");
const labels = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'))
.map((tab) => tab.textContent?.trim());
expect(labels).toEqual(["Activity", "Runs", "Costs", "Budgets", "Timeline"]);
expect(container.querySelector<HTMLElement>('[data-testid="audit-timeline"]')?.dataset.embedded)
.toBe("true");
});
it("renders Costs and Budgets as intentional peer sections", () => {
render("budgets");
let costs = container.querySelector<HTMLElement>('[data-testid="audit-costs"]');
expect(costs?.dataset.initialTab).toBe("budgets");
expect(costs?.dataset.lockTab).toBe("true");
flushSync(() => root.unmount());
render("costs");
costs = container.querySelector<HTMLElement>('[data-testid="audit-costs"]');
expect(costs?.dataset.initialTab).toBe("overview");
expect(costs?.dataset.hideBudgets).toBe("true");
});
it("keeps the current entity scope when moving between Audit sections", () => {
currentSearch = "entityType=routine&entityId=routine-1";
render("activity");
const runsTab = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'))
.find((tab) => tab.textContent?.trim() === "Runs");
expect(runsTab).toBeTruthy();
flushSync(() => {
runsTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
runsTab!.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/activity/runs?entityType=routine&entityId=routine-1",
);
});
});

View File

@ -0,0 +1,104 @@
import { useCallback, useEffect } from "react";
import { History } from "lucide-react";
import { EmptyState } from "@/components/EmptyState";
import { PageTabBar } from "@/components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useNavigate, useSearchParams } from "@/lib/router";
import { Costs } from "@/pages/Costs";
import { Timeline } from "@/pages/Timeline";
import { AuditFeed, type AuditFeedMode } from "./AuditFeed";
import { AuditRuns } from "./AuditRuns";
import { RoutineAuditActivity } from "./RoutineAuditActivity";
import {
AUDIT_SECTIONS,
auditScopeFromSearchParams,
auditSectionHref,
type AuditSection,
} from "./audit-navigation";
export function AuditHub({ section }: { section: AuditSection }) {
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams, setSearchParams] = useSearchParams();
const scope = auditScopeFromSearchParams(searchParams);
const mode: AuditFeedMode = scope.mode === "agents" ? "agents" : "all";
const routineId = scope.entityType === "routine" ? scope.entityId ?? undefined : undefined;
useEffect(() => {
const current = AUDIT_SECTIONS.find((candidate) => candidate.value === section);
setBreadcrumbs([
{ label: "Audit", href: section === "activity" ? undefined : "/activity" },
...(section === "activity" || !current ? [] : [{ label: current.label }]),
]);
}, [section, setBreadcrumbs]);
const handleModeChange = useCallback(
(next: AuditFeedMode) => {
setSearchParams(
(current) => {
const params = new URLSearchParams(current);
if (next === "agents") params.set("mode", "agents");
else params.delete("mode");
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
if (!selectedCompanyId) {
return <EmptyState icon={History} message="Select an organization to view Audit." />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight text-foreground">Audit</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
Review what happened, inspect agent runs, and understand the costs and budget controls
behind your organization.
</p>
</div>
<Tabs
value={section}
onValueChange={(value) => {
const next = value as AuditSection;
navigate(auditSectionHref(next, scope));
}}
>
<PageTabBar items={AUDIT_SECTIONS} value={section} align="start" />
</Tabs>
{section === "activity" && routineId ? (
<RoutineAuditActivity companyId={selectedCompanyId} routineId={routineId} />
) : section === "activity" ? (
<AuditFeed
companyId={selectedCompanyId}
hideHeader
mode={mode}
onModeChange={handleModeChange}
lockedAgentId={scope.agentId ?? undefined}
lockedRunId={scope.runId ?? undefined}
lockedEntity={
scope.entityType && scope.entityId
? { type: scope.entityType, id: scope.entityId }
: undefined
}
/>
) : section === "runs" ? (
<AuditRuns companyId={selectedCompanyId} routineId={routineId} />
) : section === "budgets" ? (
<Costs embedded initialTab="budgets" lockTab />
) : section === "timeline" ? (
<Timeline embedded />
) : (
<Costs embedded initialTab="overview" hideBudgetsTab />
)}
</div>
);
}

View File

@ -0,0 +1,154 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { HeartbeatRun, RoutineRunSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuditRuns } from "./AuditRuns";
const listAgentsMock = vi.hoisted(() => vi.fn());
const listRunsMock = vi.hoisted(() => vi.fn());
const listRoutineRunsMock = vi.hoisted(() => vi.fn());
const setSearchParamsMock = vi.hoisted(() => vi.fn());
let currentSearch = "";
vi.mock("@/api/agents", () => ({
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
}));
vi.mock("@/api/heartbeats", () => ({
heartbeatsApi: {
list: (companyId: string, agentId?: string, limit?: number, options?: unknown) =>
listRunsMock(companyId, agentId, limit, options),
},
}));
vi.mock("@/api/routines", () => ({
routinesApi: {
listRuns: (routineId: string, limit?: number) => listRoutineRunsMock(routineId, limit),
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>{children}</a>
),
useSearchParams: () => [new URLSearchParams(currentSearch), setSearchParamsMock],
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function run(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
return {
id: "run-12345678",
companyId: "company-1",
agentId: "agent-1",
invocationSource: "manual",
status: "succeeded",
startedAt: new Date("2026-08-31T18:00:00.000Z"),
finishedAt: new Date("2026-08-31T18:01:05.000Z"),
resultJson: { summary: "Reviewed the release checklist" },
error: null,
createdAt: new Date("2026-08-31T18:00:00.000Z"),
...overrides,
} as HeartbeatRun;
}
function routineRun(overrides: Partial<RoutineRunSummary> = {}): RoutineRunSummary {
return {
id: "routine-run-1",
companyId: "company-1",
routineId: "routine-1",
triggerId: "trigger-1",
source: "schedule",
status: "succeeded",
triggeredAt: new Date("2026-08-31T18:00:00.000Z"),
idempotencyKey: null,
triggerPayload: null,
dispatchFingerprint: null,
linkedIssueId: "issue-1",
coalescedIntoRunId: null,
failureReason: null,
completedAt: new Date("2026-08-31T18:01:05.000Z"),
createdAt: new Date("2026-08-31T18:00:00.000Z"),
updatedAt: new Date("2026-08-31T18:01:05.000Z"),
linkedIssue: { id: "issue-1", identifier: "TES-42", title: "Publish forecast" },
trigger: { id: "trigger-1", kind: "schedule", label: "Daily forecast" },
...overrides,
} as RoutineRunSummary;
}
async function flushReact() {
for (let index = 0; index < 3; index += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
}
describe("AuditRuns", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
currentSearch = "";
container = document.createElement("div");
document.body.appendChild(container);
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable" }]);
listRunsMock.mockResolvedValue([run()]);
listRoutineRunsMock.mockResolvedValue([routineRun()]);
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
async function render(routineId?: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<AuditRuns companyId="company-1" routineId={routineId} />
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders a filterable flat run list with existing run-detail links", async () => {
await render();
expect(listRunsMock).toHaveBeenCalledWith("company-1", undefined, 200, { summary: true });
expect(container.textContent).toContain("Agent");
expect(container.textContent).toContain("Status");
expect(container.textContent).toContain("Reviewed the release checklist");
expect(container.textContent).toContain("1m 5s");
const list = container.querySelector('ul[aria-label="Recent runs"]');
expect(list).toBeTruthy();
expect(list?.closest('[data-slot="card"]')).toBeFalsy();
expect(container.querySelector('a[href="/agents/agent-1/runs/run-12345678"]')).toBeTruthy();
});
it("uses the agent deep-link filter for both the query key and request", async () => {
currentSearch = "agentId=agent-1&runStatus=succeeded";
await render();
expect(listRunsMock).toHaveBeenCalledWith("company-1", "agent-1", 200, { summary: true });
expect(container.textContent).toContain("Clear filters");
});
it("loads routine runs directly when the Audit scope is a routine", async () => {
await render("routine-1");
expect(listRoutineRunsMock).toHaveBeenCalledWith("routine-1", 200);
expect(listAgentsMock).not.toHaveBeenCalled();
expect(listRunsMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Publish forecast");
expect(container.textContent).toContain("Daily forecast");
expect(container.querySelector('a[href="/issues/TES-42"]')).toBeTruthy();
});
});

View File

@ -0,0 +1,312 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { HeartbeatRun, RoutineRunSummary } from "@paperclipai/shared";
import { Activity, CircleDotDashed } from "lucide-react";
import { agentsApi } from "@/api/agents";
import { heartbeatsApi } from "@/api/heartbeats";
import { routinesApi } from "@/api/routines";
import { EmptyState } from "@/components/EmptyState";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { queryKeys } from "@/lib/queryKeys";
import { Link, useSearchParams } from "@/lib/router";
import { relativeTime } from "@/lib/utils";
const ALL = "__all";
const RUN_LIMIT = 200;
function runSummary(run: HeartbeatRun) {
const result = run.resultJson as { summary?: unknown; result?: unknown } | null;
const value = result?.summary ?? result?.result ?? run.error;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function runDuration(run: HeartbeatRun) {
const start = run.startedAt ? new Date(run.startedAt).getTime() : null;
const end = run.finishedAt ? new Date(run.finishedAt).getTime() : null;
if (start == null || end == null || !Number.isFinite(start) || !Number.isFinite(end)) return null;
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
function readableSource(source: string) {
return source.replaceAll("_", " ");
}
function routineRunTitle(run: RoutineRunSummary) {
return run.linkedIssue?.title ?? run.trigger?.label ?? "Routine run";
}
function RoutineScopedRuns({
runs,
isLoading,
error,
onRetry,
}: {
runs: RoutineRunSummary[];
isLoading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (isLoading) {
return (
<div className="border-y border-border py-14 text-center text-sm text-muted-foreground">
Loading routine runs
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center gap-3 border-y border-border py-14 text-center">
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button variant="outline" size="sm" onClick={onRetry}>Try again</Button>
</div>
);
}
if (runs.length === 0) {
return <EmptyState icon={Activity} message="No routine runs yet." />;
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Routine runs</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Executions created by this routine, newest first.
</p>
</div>
<ul className="divide-y divide-border border-y border-border" aria-label="Routine runs">
{runs.map((run) => {
const content = (
<>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{routineRunTitle(run)}</span>
<StatusBadge status={run.status} />
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{run.trigger?.label ?? readableSource(run.source)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground sm:justify-end">
<span className="capitalize">{readableSource(run.source)}</span>
<time dateTime={new Date(run.triggeredAt).toISOString()}>
{relativeTime(run.triggeredAt)}
</time>
</div>
</>
);
const rowClassName = "flex flex-col gap-2 px-1 py-3 text-inherit no-underline transition-colors hover:bg-muted/50 sm:flex-row sm:items-start sm:justify-between sm:px-3";
return (
<li key={run.id}>
{run.linkedIssue ? (
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className={rowClassName}>
{content}
</Link>
) : (
<div className={rowClassName}>{content}</div>
)}
</li>
);
})}
</ul>
<p className="text-xs text-muted-foreground">Showing the {RUN_LIMIT} most recent routine runs.</p>
</div>
);
}
export function AuditRuns({ companyId, routineId }: { companyId: string; routineId?: string }) {
const [searchParams, setSearchParams] = useSearchParams();
const agentId = searchParams.get("agentId") ?? ALL;
const status = searchParams.get("runStatus") ?? ALL;
const agents = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
enabled: !routineId,
});
const runs = useQuery({
queryKey: queryKeys.audit.runs(companyId, agentId === ALL ? null : agentId),
queryFn: () =>
heartbeatsApi.list(companyId, agentId === ALL ? undefined : agentId, RUN_LIMIT, {
summary: true,
}),
refetchInterval: 15_000,
enabled: !routineId,
});
const routineRuns = useQuery({
queryKey: [...queryKeys.routines.runs(routineId ?? ""), "audit"],
queryFn: () => routinesApi.listRuns(routineId!, RUN_LIMIT),
enabled: Boolean(routineId),
refetchInterval: 15_000,
});
const agentById = useMemo(
() => new Map((agents.data ?? []).map((agent) => [agent.id, agent])),
[agents.data],
);
const statuses = useMemo(
() => Array.from(new Set((runs.data ?? []).map((run) => run.status))).sort(),
[runs.data],
);
const visibleRuns = useMemo(
() => (runs.data ?? []).filter((run) => status === ALL || run.status === status),
[runs.data, status],
);
const updateFilter = (key: "agentId" | "runStatus", value: string) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (value === ALL) next.delete(key);
else next.set(key, value);
return next;
},
{ replace: true },
);
};
const clearFilters = () => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.delete("agentId");
next.delete("runStatus");
return next;
},
{ replace: true },
);
};
if (routineId) {
return (
<RoutineScopedRuns
runs={routineRuns.data ?? []}
isLoading={routineRuns.isLoading}
error={routineRuns.error instanceof Error ? routineRuns.error : null}
onRetry={() => void routineRuns.refetch()}
/>
);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Runs</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Recent agent executions across the organization. Open a run to inspect its transcript,
output, and task context.
</p>
</div>
<div className="flex flex-wrap items-end gap-3 border-y border-border py-3">
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Agent</span>
<Select value={agentId} onValueChange={(value) => updateFilter("agentId", value)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All agents" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{(agents.data ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Status</span>
<Select value={status} onValueChange={(value) => updateFilter("runStatus", value)}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
{statuses.map((value) => (
<SelectItem key={value} value={value}>
{readableSource(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{agentId !== ALL || status !== ALL ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
</div>
{runs.isLoading ? (
<div className="border-y border-border py-14 text-center text-sm text-muted-foreground">
Loading runs
</div>
) : runs.error ? (
<div className="flex flex-col items-center gap-3 border-y border-border py-14 text-center">
<p className="text-sm text-muted-foreground">
{runs.error instanceof Error ? runs.error.message : "Failed to load runs."}
</p>
<Button variant="outline" size="sm" onClick={() => runs.refetch()}>
Try again
</Button>
</div>
) : visibleRuns.length === 0 ? (
<EmptyState
icon={agentId !== ALL || status !== ALL ? CircleDotDashed : Activity}
message={agentId !== ALL || status !== ALL ? "No runs match these filters." : "No runs yet."}
/>
) : (
<ul className="divide-y divide-border border-y border-border" aria-label="Recent runs">
{visibleRuns.map((run) => {
const agent = agentById.get(run.agentId);
const summary = runSummary(run);
const duration = runDuration(run);
return (
<li key={run.id}>
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex flex-col gap-2 px-1 py-3 text-inherit no-underline transition-colors hover:bg-muted/50 sm:flex-row sm:items-start sm:justify-between sm:px-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{agent?.name ?? "Unknown agent"}
</span>
<span className="font-mono text-(length:--text-micro) text-muted-foreground">
{run.id.slice(0, 8)}
</span>
<StatusBadge status={run.status} />
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{summary ?? `${readableSource(run.invocationSource)} run`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground sm:justify-end">
<span className="capitalize">{readableSource(run.invocationSource)}</span>
{duration ? <span>{duration}</span> : null}
<time dateTime={new Date(run.createdAt).toISOString()}>
{relativeTime(run.createdAt)}
</time>
</div>
</Link>
</li>
);
})}
</ul>
)}
<p className="text-xs text-muted-foreground">Showing the {RUN_LIMIT} most recent runs.</p>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { useCallback, useEffect } from "react";
import { History } from "lucide-react";
import { useSearchParams } from "@/lib/router";
import { useCompany } from "../../context/CompanyContext";
import { useBreadcrumbs } from "../../context/BreadcrumbContext";
import { EmptyState } from "../../components/EmptyState";
import { AuditFeed, type AuditFeedMode } from "./AuditFeed.production";
/**
* Company activity page the single merged surface for `/:company/activity`
* (PAP-16302). It replaces both the old 200-row activity list and the separate
* `/audit` page: all company readers get the shared all-actors feed, and callers
* with `audit:view_agent_actions` can switch to the privileged agent-action
* audit. The mode lives in `?mode=` so `/audit` deep links can preset it and
* links stay shareable. The server enforces both tiers regardless.
*/
export function CompanyActivity() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams, setSearchParams] = useSearchParams();
const mode: AuditFeedMode = searchParams.get("mode") === "agents" ? "agents" : "all";
useEffect(() => {
setBreadcrumbs([{ label: "Activity" }]);
}, [setBreadcrumbs]);
const handleModeChange = useCallback(
(next: AuditFeedMode) => {
setSearchParams(
(current) => {
const params = new URLSearchParams(current);
if (next === "agents") params.set("mode", "agents");
else params.delete("mode");
return params;
},
// The mode is a view toggle, not a navigation step — don't stack history
// entries the back button has to walk through.
{ replace: true },
);
},
[setSearchParams],
);
if (!selectedCompanyId) {
return <EmptyState icon={History} message="Select a company to view activity." />;
}
return <AuditFeed companyId={selectedCompanyId} mode={mode} onModeChange={handleModeChange} />;
}

View File

@ -3,26 +3,27 @@ import { History } from "lucide-react";
import { useSearchParams } from "@/lib/router";
import { useCompany } from "../../context/CompanyContext";
import { useBreadcrumbs } from "../../context/BreadcrumbContext";
import { useStreamlinedUiEnabled } from "../../hooks/useStreamlinedUiEnabled";
import { EmptyState } from "../../components/EmptyState";
import { AuditFeed, type AuditFeedMode } from "./AuditFeed";
import { AuditHub } from "./AuditHub";
/**
* Company activity page the single merged surface for `/:company/activity`
* (PAP-16302). It replaces both the old 200-row activity list and the separate
* `/audit` page: all company readers get the shared all-actors feed, and callers
* with `audit:view_agent_actions` can switch to the privileged agent-action
* audit. The mode lives in `?mode=` so `/audit` deep links can preset it and
* links stay shareable. The server enforces both tiers regardless.
* Canonical `/:company/activity` entrypoint for the Audit hub. It retains the
* shared all-actors and privileged Agent Actions modes while Runs, Costs,
* Budgets, and Timeline live as peer sections. The mode lives in `?mode=` so `/audit` deep
* links can preset it and links stay shareable. The server enforces both tiers.
*/
export function CompanyActivity() {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams, setSearchParams] = useSearchParams();
const mode: AuditFeedMode = searchParams.get("mode") === "agents" ? "agents" : "all";
useEffect(() => {
setBreadcrumbs([{ label: "Activity" }]);
}, [setBreadcrumbs]);
if (!streamlinedUiEnabled) setBreadcrumbs([{ label: "Activity" }]);
}, [setBreadcrumbs, streamlinedUiEnabled]);
const handleModeChange = useCallback(
(next: AuditFeedMode) => {
@ -33,14 +34,14 @@ export function CompanyActivity() {
else params.delete("mode");
return params;
},
// The mode is a view toggle, not a navigation step — don't stack history
// entries the back button has to walk through.
{ replace: true },
);
},
[setSearchParams],
);
if (streamlinedUiEnabled) return <AuditHub section="activity" />;
if (!selectedCompanyId) {
return <EmptyState icon={History} message="Select an organization to view activity." />;
}

View File

@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineAuditActivity } from "./RoutineAuditActivity";
const getRoutineMock = vi.hoisted(() => vi.fn());
const listRoutineRunsMock = vi.hoisted(() => vi.fn());
const routineActivityMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/routines", () => ({
routinesApi: {
get: (routineId: string) => getRoutineMock(routineId),
listRuns: (routineId: string, limit?: number) => listRoutineRunsMock(routineId, limit),
activity: (
companyId: string,
routineId: string,
scope: { triggerIds: string[]; runIds: string[] },
) => routineActivityMock(companyId, routineId, scope),
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
for (let index = 0; index < 3; index += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
}
describe("RoutineAuditActivity", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
getRoutineMock.mockResolvedValue({ triggers: [{ id: "trigger-1" }, { id: "trigger-2" }] });
listRoutineRunsMock.mockResolvedValue([{ id: "run-1" }, { id: "run-2" }]);
routineActivityMock.mockResolvedValue([
{
id: "event-1",
action: "routine.run.completed",
details: { runId: "run-1" },
createdAt: new Date("2026-08-31T18:01:05.000Z"),
},
]);
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
it("loads activity through the routine endpoint with its trigger and run scope", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<RoutineAuditActivity companyId="company-1" routineId="routine-1" />
</QueryClientProvider>,
);
});
await flushReact();
expect(getRoutineMock).toHaveBeenCalledWith("routine-1");
expect(listRoutineRunsMock).toHaveBeenCalledWith("routine-1", 200);
expect(routineActivityMock).toHaveBeenCalledWith("company-1", "routine-1", {
triggerIds: ["trigger-1", "trigger-2"],
runIds: ["run-1", "run-2"],
});
expect(container.textContent).toContain("routine.run.completed");
});
});

View File

@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import { Activity } from "lucide-react";
import { routinesApi } from "@/api/routines";
import { EmptyState } from "@/components/EmptyState";
import { RoutineActivityRow } from "@/components/RoutineActivityRow";
import { Button } from "@/components/ui/button";
import { queryKeys } from "@/lib/queryKeys";
export function RoutineAuditActivity({
companyId,
routineId,
}: {
companyId: string;
routineId: string;
}) {
const activity = useQuery({
queryKey: [...queryKeys.routines.activity(companyId, routineId), "audit"],
queryFn: async () => {
const [routine, runs] = await Promise.all([
routinesApi.get(routineId),
routinesApi.listRuns(routineId, 200),
]);
return routinesApi.activity(companyId, routineId, {
triggerIds: routine.triggers.map((trigger) => trigger.id),
runIds: runs.map((run) => run.id),
});
},
});
if (activity.isLoading) {
return (
<div className="border-y border-border py-14 text-center text-sm text-muted-foreground">
Loading routine activity
</div>
);
}
if (activity.error) {
return (
<div className="flex flex-col items-center gap-3 border-y border-border py-14 text-center">
<p className="text-sm text-muted-foreground">
{activity.error instanceof Error ? activity.error.message : "Failed to load routine activity."}
</p>
<Button variant="outline" size="sm" onClick={() => activity.refetch()}>
Try again
</Button>
</div>
);
}
const events = activity.data ?? [];
if (events.length === 0) {
return <EmptyState icon={Activity} message="No routine activity yet." />;
}
return (
<div className="border-y border-border" aria-label="Routine activity">
{events.map((event) => (
<RoutineActivityRow key={event.id} event={event} />
))}
</div>
);
}

View File

@ -1,9 +1,14 @@
import { useMemo, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DiscoveryGrid, type DiscoveryCard, type DiscoveryCategory } from "@/pages/CompanySkills";
import {
DiscoveryGrid,
type DiscoveryCard,
type DiscoveryCategory,
type DiscoveryTab,
} from "@/pages/CompanySkills";
type DiscoveryTab = "all" | "installed" | "catalog" | "bundled";
type DiscoverySort = "agents" | "stars" | "forks" | "recent" | "alphabetical";
const STORY_NOW = new Date("2026-08-31T12:00:00Z").getTime();
const MOCK_CARDS: DiscoveryCard[] = [
{
@ -25,7 +30,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: false,
forkedFrom: false,
updatedAt: Date.now() - 2 * 86_400_000,
updatedAt: STORY_NOW - 2 * 86_400_000,
sourceBadge: "github",
},
{
@ -47,7 +52,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: false,
required: false,
forkedFrom: false,
updatedAt: Date.now() - 5 * 86_400_000,
updatedAt: STORY_NOW - 5 * 86_400_000,
sourceBadge: "skills_sh",
},
{
@ -69,7 +74,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: false,
forkedFrom: false,
updatedAt: Date.now() - 9 * 86_400_000,
updatedAt: STORY_NOW - 9 * 86_400_000,
sourceBadge: "local",
},
{
@ -91,7 +96,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: false,
forkedFrom: true,
updatedAt: Date.now() - 1 * 86_400_000,
updatedAt: STORY_NOW - 1 * 86_400_000,
sourceBadge: "paperclip",
},
{
@ -135,7 +140,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: false,
forkedFrom: false,
updatedAt: Date.now() - 3 * 86_400_000,
updatedAt: STORY_NOW - 3 * 86_400_000,
sourceBadge: "github",
},
{
@ -179,7 +184,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: false,
forkedFrom: false,
updatedAt: Date.now() - 4 * 86_400_000,
updatedAt: STORY_NOW - 4 * 86_400_000,
sourceBadge: "local",
},
{
@ -201,7 +206,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: true,
forkedFrom: false,
updatedAt: Date.now() - 30 * 86_400_000,
updatedAt: STORY_NOW - 30 * 86_400_000,
sourceBadge: "paperclip",
},
{
@ -223,22 +228,17 @@ const MOCK_CARDS: DiscoveryCard[] = [
installed: true,
required: true,
forkedFrom: false,
updatedAt: Date.now() - 28 * 86_400_000,
updatedAt: STORY_NOW - 28 * 86_400_000,
sourceBadge: "url",
},
];
const DISCOVERY_TABS: DiscoveryTab[] = ["all", "installed", "catalog", "bundled"];
function cardsForTab(cards: DiscoveryCard[], tab: DiscoveryTab): DiscoveryCard[] {
if (tab === "installed") return cards.filter((c) => c.installed);
if (tab === "catalog") return cards.filter((c) => c.catalogRef != null);
if (tab === "bundled") return cards.filter((c) => c.required);
return cards;
return tab === "installed" ? cards.filter((card) => card.installed) : cards;
}
function DiscoveryGridHarness({
initialTab = "all",
initialTab = "installed",
cards = MOCK_CARDS,
}: {
initialTab?: DiscoveryTab;
@ -264,7 +264,7 @@ function DiscoveryGridHarness({
if (!q) return true;
return `${card.name} ${card.author} ${card.categories.join(" ")}`.toLowerCase().includes(q);
});
const demote = tab !== "bundled";
const demote = tab === "discover";
return [...filtered].sort((a, b) => {
if (demote && a.required !== b.required) return a.required ? 1 : -1;
if (sort === "stars") return b.starCount - a.starCount;
@ -275,24 +275,9 @@ function DiscoveryGridHarness({
});
}, [tabCards, category, search, sort, tab]);
const tabCounts = useMemo(
() => ({
all: cards.length,
installed: cards.filter((c) => c.installed).length,
catalog: cards.filter((c) => c.catalogRef != null).length,
bundled: cards.filter((c) => c.required).length,
}),
[cards],
) as Record<DiscoveryTab, number>;
return (
<DiscoveryGrid
tab={tab}
tabCounts={tabCounts}
onTabChange={(next) => {
setTab(next);
setCategory(null);
}}
categories={categories}
categoryTotal={tabCards.length}
activeCategory={category}
@ -309,7 +294,10 @@ function DiscoveryGridHarness({
onCreate={() => {}}
onImport={() => {}}
onImportFromProject={() => {}}
onBrowseCatalog={() => setTab("catalog")}
onBrowseDiscover={() => {
setTab("discover");
setCategory(null);
}}
onScan={() => {}}
scanPending={false}
scanStatus={null}
@ -327,7 +315,6 @@ export default meta;
type Story = StoryObj<typeof DiscoveryGridHarness>;
export const AllSkills: Story = { args: { initialTab: "all" } };
export const InstalledTab: Story = { args: { initialTab: "installed" } };
export const BundledRequiredTab: Story = { args: { initialTab: "bundled" } };
export const EmptyLibrary: Story = { args: { initialTab: "all", cards: [] } };
export const Installed: Story = { args: { initialTab: "installed" } };
export const Discover: Story = { args: { initialTab: "discover" } };
export const EmptyInstalledLibrary: Story = { args: { initialTab: "installed", cards: [] } };