feat(ui): refine streamlined workspace surfaces (#12747)
This commit is contained in:
parent
597fd63b61
commit
b1f4910ee5
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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." />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: [] } };
|
||||
|
|
|
|||
Loading…
Reference in New Issue