feat(ui): routine detail page — variation C sub-sidebar layout (PAP-10732) (#7848)
## Summary Rebuilds the routine detail page as **variation C** — a sub-sidebar shell that splits the page into **ROUTINE** (Overview · Triggers · Variables · Secrets · Delivery) and **OPERATE** (Runs · Activity · History), per the engineering spec on PAP-10730. Replaces the previous 5-tab `?tab=…` layout in `ui/src/pages/RoutineDetail.tsx`. Implements PAP-10732. Design source of truth: PAP-10730 `spec` document; approved direction PAP-10709. ## What changed - **Routing** (`ui/src/App.tsx`): real sub-routes under `routines/:routineId/:section`. Bare `/routines/:id` redirects to the last-viewed section (`localStorage`) or `overview`; old `?tab=…` URLs redirect to the matching section for back-compat. Every section URL is bookmarkable. - **Shell** (`RoutineDetail.tsx`): slim 56px sticky header (title + managed-by-plugin chip + Run / Active toggle), page-local sub-sidebar, full-canvas section body, per-section sticky save bar. All routine state/mutations stay in the shell and flow to sections via a `RoutineDetailContext`. - **New components**: `RoutineSubSidebar` (+ mobile `<Select>` picker, roving keyboard nav), `RoutineSaveBar` (scoped dirty count, ⌘/Ctrl+S save, Esc-discard confirm, 409 conflict recovery with Reload / Overwrite), `RadioCard` primitive (Delivery), `RoutineTriggerCard` (extracted from the inline editor, with human-readable cron), `RoutineActivityRow` (expandable JSON), `lib/cron-readable`, and the per-section components. - **Reuse**: History mounts the existing `RoutineHistoryTab`; Variables mounts `RoutineVariablesEditor` with a provenance banner; Secrets reuses `EnvVarEditor` + the one-time reveal banner. No backend or schema changes. - **States**: per-section loading/empty/error/save-conflict and read-only strip scaffolding (§1.6). ## Testing - New unit tests: sub-sidebar navigation/active/dirty markers, save-bar dirty + ⌘S + conflict recovery, cron helper. - Existing routine tests still pass: `Routines.test.tsx`, `RoutineHistoryTab.test.tsx`, `RoutineRunVariablesDialog.test.tsx`. - `vitest run` (routine scope): **36 passed**. Production `vite build`: **green**. - Screenshots at 1440×900 + 390×844 attached to [PAP-10732](https://example.invalid) (rendered via a new Storybook story with fixture data). ## Out of scope (per spec) - `/routines` list-page redo (follow-up). - Non-owner secret-value visibility (Open Q6 — CEO escalation; built with the spec default). --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -130,6 +130,7 @@ function boardRoutes() {
|
|||||||
) : null}
|
) : null}
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
|
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { ActivityEvent } from "@paperclipai/shared";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type RoutineActivityEvent = Pick<ActivityEvent, "id" | "action" | "details" | "createdAt">;
|
||||||
|
|
||||||
|
function formatTime(value: string | Date): string {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeDetails(details: Record<string, unknown> | null | undefined): string {
|
||||||
|
if (!details) return "";
|
||||||
|
const entries = Object.entries(details).slice(0, 3);
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => `${key.replaceAll("_", " ")}: ${formatDetailValue(value)}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetailValue(value: unknown): string {
|
||||||
|
if (value === null) return "null";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (Array.isArray(value)) return value.length === 0 ? "[]" : value.map(formatDetailValue).join(", ");
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activity log row with an expandable JSON payload (§3.7). */
|
||||||
|
export function RoutineActivityRow({ event }: { event: RoutineActivityEvent }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasPayload = event.details != null && Object.keys(event.details).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/60 last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasPayload}
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 px-1 py-2 text-left text-xs",
|
||||||
|
hasPayload ? "hover:bg-accent/30" : "cursor-default",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="w-12 shrink-0 font-mono text-muted-foreground/70">
|
||||||
|
{formatTime(event.createdAt)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="shrink-0 font-mono">
|
||||||
|
{event.action}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||||
|
{summarizeDetails(event.details)}
|
||||||
|
</span>
|
||||||
|
{hasPayload ? (
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||||
|
expanded && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{expanded && hasPayload ? (
|
||||||
|
<pre className="mx-1 mb-2 overflow-x-auto rounded-md bg-neutral-950 p-3 font-mono text-xs text-neutral-200">
|
||||||
|
{JSON.stringify(event.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RoutineSaveBar } from "./RoutineSaveBar";
|
||||||
|
import type { RoutineHistoryDirtyFieldDescriptor } from "./RoutineHistoryTab";
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const DIRTY: RoutineHistoryDirtyFieldDescriptor[] = [
|
||||||
|
{ key: "title", label: "the title" },
|
||||||
|
{ key: "description", label: "the description" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderBar(props: Partial<React.ComponentProps<typeof RoutineSaveBar>>) {
|
||||||
|
const handlers = {
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onDiscard: vi.fn(),
|
||||||
|
onReload: vi.fn(),
|
||||||
|
};
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<RoutineSaveBar
|
||||||
|
dirtyFields={props.dirtyFields ?? []}
|
||||||
|
isSaving={props.isSaving ?? false}
|
||||||
|
saveConflict={props.saveConflict ?? false}
|
||||||
|
onSave={props.onSave ?? handlers.onSave}
|
||||||
|
onDiscard={props.onDiscard ?? handlers.onDiscard}
|
||||||
|
onReload={props.onReload ?? handlers.onReload}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoutineSaveBar", () => {
|
||||||
|
it("renders nothing when clean and no conflict", () => {
|
||||||
|
renderBar({ dirtyFields: [] });
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the unsaved change count when dirty", () => {
|
||||||
|
renderBar({ dirtyFields: DIRTY });
|
||||||
|
expect(container.textContent).toContain("2 unsaved changes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onSave on Cmd/Ctrl+S while dirty", () => {
|
||||||
|
const handlers = renderBar({ dirtyFields: DIRTY });
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", metaKey: true }));
|
||||||
|
});
|
||||||
|
expect(handlers.onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke onSave on Cmd+S when clean", () => {
|
||||||
|
const handlers = renderBar({ dirtyFields: [] });
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", metaKey: true }));
|
||||||
|
});
|
||||||
|
expect(handlers.onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the conflict recovery actions on saveConflict", () => {
|
||||||
|
const handlers = renderBar({ dirtyFields: DIRTY, saveConflict: true });
|
||||||
|
expect(container.textContent).toContain("Routine changed elsewhere");
|
||||||
|
const buttons = Array.from(container.querySelectorAll("button"));
|
||||||
|
const reload = buttons.find((button) => button.textContent?.includes("Reload latest"));
|
||||||
|
const overwrite = buttons.find((button) => button.textContent?.includes("Overwrite anyway"));
|
||||||
|
expect(reload).toBeTruthy();
|
||||||
|
expect(overwrite).toBeTruthy();
|
||||||
|
act(() => {
|
||||||
|
reload!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(handlers.onReload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { RoutineHistoryDirtyFieldDescriptor } from "./RoutineHistoryTab";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-section sticky save bar (§1.4–§1.5). Hidden when clean; reveals on dirty.
|
||||||
|
* On a 409 it swaps to the conflict-recovery surface ("Reload latest" /
|
||||||
|
* "Overwrite anyway"). Wires ⌘/Ctrl+S → save and Esc → discard-with-confirm.
|
||||||
|
*/
|
||||||
|
export function RoutineSaveBar({
|
||||||
|
dirtyFields,
|
||||||
|
isSaving,
|
||||||
|
saveConflict,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
onReload,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
dirtyFields: RoutineHistoryDirtyFieldDescriptor[];
|
||||||
|
isSaving: boolean;
|
||||||
|
saveConflict: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onReload: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const dirtyCount = dirtyFields.length;
|
||||||
|
const isDirty = dirtyCount > 0;
|
||||||
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty && !saveConflict) return;
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isSaving && !disabled) onSave();
|
||||||
|
} else if (event.key === "Escape" && isDirty) {
|
||||||
|
event.preventDefault();
|
||||||
|
setConfirmDiscardOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [isDirty, saveConflict, isSaving, disabled, onSave]);
|
||||||
|
|
||||||
|
if (!isDirty && !saveConflict) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sticky bottom-0 z-10 -mx-8 mt-6 flex h-14 items-center justify-between border-t px-8 backdrop-blur",
|
||||||
|
"motion-safe:transition-colors motion-safe:duration-200",
|
||||||
|
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
|
||||||
|
saveConflict
|
||||||
|
? "border-amber-500/30 bg-amber-500/5"
|
||||||
|
: "border-border bg-background/95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saveConflict ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-200">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span>Routine changed elsewhere. Reload to merge.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 text-sm text-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{dirtyCount} unsaved {dirtyCount === 1 ? "change" : "changes"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-64">
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Pending changes
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{dirtyFields.map((field) => (
|
||||||
|
<li key={field.key} className="flex items-center gap-2">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-amber-500" />
|
||||||
|
<span className="capitalize">{field.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{saveConflict ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={onReload}>
|
||||||
|
Reload latest
|
||||||
|
</Button>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isSaving || disabled}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Overwrite anyway
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Replaces the newer revision with your local edits.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isSaving || disabled}
|
||||||
|
onClick={() => setConfirmDiscardOpen(true)}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isSaving || disabled}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Save changes
|
||||||
|
<kbd className="ml-2 hidden rounded bg-foreground/10 px-1 text-[10px] font-medium sm:inline">
|
||||||
|
⌘S
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={confirmDiscardOpen} onOpenChange={setConfirmDiscardOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Discard changes?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will revert {dirtyCount} unsaved{" "}
|
||||||
|
{dirtyCount === 1 ? "change" : "changes"} in this section.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setConfirmDiscardOpen(false)}>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onDiscard();
|
||||||
|
setConfirmDiscardOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only strip for non-owners on editable sections (§1.6). */
|
||||||
|
export function RoutineReadOnlyStrip() {
|
||||||
|
return (
|
||||||
|
<div className="-mx-8 mt-6 border-t border-border bg-muted/20 px-8 py-3 text-xs text-muted-foreground">
|
||||||
|
Read-only — you don't own this routine.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({
|
||||||
|
to,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children: ReactNode; replace?: boolean }) => {
|
||||||
|
const { replace: _replace, ...rest } = props as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
<a href={to} {...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RoutineSubSidebar } from "./RoutineSubSidebar";
|
||||||
|
import type { RoutineSectionKey } from "./routine-sections/context";
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSidebar(overrides?: {
|
||||||
|
activeSection?: RoutineSectionKey;
|
||||||
|
dirty?: RoutineSectionKey[];
|
||||||
|
hasLiveRun?: boolean;
|
||||||
|
onNavigate?: (section: RoutineSectionKey) => void;
|
||||||
|
}) {
|
||||||
|
const dirty = new Set(overrides?.dirty ?? []);
|
||||||
|
const onNavigate = overrides?.onNavigate ?? vi.fn();
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<RoutineSubSidebar
|
||||||
|
activeSection={overrides?.activeSection ?? "overview"}
|
||||||
|
hrefFor={(section) => `/routines/r1/${section}`}
|
||||||
|
isSectionDirty={(section) => dirty.has(section)}
|
||||||
|
hasLiveRun={overrides?.hasLiveRun ?? false}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { onNavigate };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoutineSubSidebar", () => {
|
||||||
|
it("renders all eight sections grouped under ROUTINE and OPERATE", () => {
|
||||||
|
renderSidebar();
|
||||||
|
const links = Array.from(container.querySelectorAll("a"));
|
||||||
|
const labels = links.map((link) => link.textContent?.trim());
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"Overview",
|
||||||
|
"Triggers",
|
||||||
|
"Variables",
|
||||||
|
"Secrets",
|
||||||
|
"Delivery",
|
||||||
|
"Runs",
|
||||||
|
"Activity",
|
||||||
|
"History",
|
||||||
|
]);
|
||||||
|
const groupLabels = Array.from(container.querySelectorAll("p")).map((p) => p.textContent);
|
||||||
|
expect(groupLabels).toContain("Routine");
|
||||||
|
expect(groupLabels).toContain("Operate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the active section with aria-current=page", () => {
|
||||||
|
renderSidebar({ activeSection: "secrets" });
|
||||||
|
const active = container.querySelector('a[aria-current="page"]');
|
||||||
|
expect(active?.textContent?.trim()).toBe("Secrets");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links each section to its section URL", () => {
|
||||||
|
renderSidebar();
|
||||||
|
const variables = Array.from(container.querySelectorAll("a")).find(
|
||||||
|
(link) => link.textContent?.trim() === "Variables",
|
||||||
|
);
|
||||||
|
expect(variables?.getAttribute("href")).toBe("/routines/r1/variables");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a dirty marker only on dirty editable sections", () => {
|
||||||
|
renderSidebar({ dirty: ["overview", "delivery"] });
|
||||||
|
const dirtyMarkers = container.querySelectorAll('[aria-label="Unsaved changes"]');
|
||||||
|
expect(dirtyMarkers.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onNavigate when a section is clicked", () => {
|
||||||
|
const { onNavigate } = renderSidebar();
|
||||||
|
const triggers = Array.from(container.querySelectorAll("a")).find(
|
||||||
|
(link) => link.textContent?.trim() === "Triggers",
|
||||||
|
) as HTMLAnchorElement;
|
||||||
|
act(() => {
|
||||||
|
triggers.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("triggers");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
Activity as ActivityIcon,
|
||||||
|
Circle,
|
||||||
|
Clock3,
|
||||||
|
History as HistoryIcon,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Play,
|
||||||
|
Send,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ROUTINE_SECTION_KEYS,
|
||||||
|
type RoutineSectionKey,
|
||||||
|
} from "./routine-sections/context";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
key: RoutineSectionKey;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroup = {
|
||||||
|
label: string;
|
||||||
|
items: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Routine",
|
||||||
|
items: [
|
||||||
|
{ key: "overview", label: "Overview", icon: Circle },
|
||||||
|
{ key: "triggers", label: "Triggers", icon: Clock3 },
|
||||||
|
{ key: "variables", label: "Variables", icon: LayoutGrid },
|
||||||
|
{ key: "secrets", label: "Secrets", icon: KeyRound },
|
||||||
|
{ key: "delivery", label: "Delivery", icon: Send },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Operate",
|
||||||
|
items: [
|
||||||
|
{ key: "runs", label: "Runs", icon: Play },
|
||||||
|
{ key: "activity", label: "Activity", icon: ActivityIcon },
|
||||||
|
{ key: "history", label: "History", icon: HistoryIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_ITEMS: NavItem[] = NAV_GROUPS.flatMap((group) => group.items);
|
||||||
|
|
||||||
|
export function RoutineSubSidebar({
|
||||||
|
activeSection,
|
||||||
|
hrefFor,
|
||||||
|
isSectionDirty,
|
||||||
|
hasLiveRun,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
activeSection: RoutineSectionKey;
|
||||||
|
hrefFor: (section: RoutineSectionKey) => string;
|
||||||
|
isSectionDirty: (section: RoutineSectionKey) => boolean;
|
||||||
|
hasLiveRun: boolean;
|
||||||
|
onNavigate: (section: RoutineSectionKey) => void;
|
||||||
|
}) {
|
||||||
|
const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
||||||
|
|
||||||
|
const focusItem = (index: number) => {
|
||||||
|
const clamped = (index + ALL_ITEMS.length) % ALL_ITEMS.length;
|
||||||
|
itemRefs.current[clamped]?.focus();
|
||||||
|
onNavigate(ALL_ITEMS[clamped].key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
focusItem(index + 1);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
focusItem(index - 1);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
event.preventDefault();
|
||||||
|
focusItem(0);
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
event.preventDefault();
|
||||||
|
focusItem(ALL_ITEMS.length - 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let flatIndex = -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Routine sections"
|
||||||
|
className="sticky top-14 hidden max-h-[calc(100dvh-3.5rem)] w-52 shrink-0 flex-col gap-4 self-start overflow-y-auto border-r border-border bg-sidebar/30 px-3 py-4 md:flex"
|
||||||
|
>
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<div key={group.label} className="flex flex-col gap-0.5">
|
||||||
|
<p className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
flatIndex += 1;
|
||||||
|
const index = flatIndex;
|
||||||
|
const isActive = item.key === activeSection;
|
||||||
|
const Icon = item.icon;
|
||||||
|
const dirty = isSectionDirty(item.key);
|
||||||
|
const showLiveDot = item.key === "runs" && hasLiveRun;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
ref={(node) => {
|
||||||
|
itemRefs.current[index] = node;
|
||||||
|
}}
|
||||||
|
to={hrefFor(item.key)}
|
||||||
|
replace
|
||||||
|
role="tab"
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||||
|
onClick={() => onNavigate(item.key)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center gap-2 rounded-md px-3 text-sm transition-colors motion-safe:duration-150",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
{showLiveDot ? (
|
||||||
|
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 motion-safe:animate-pulse" />
|
||||||
|
) : dirty ? (
|
||||||
|
<span
|
||||||
|
aria-label="Unsaved changes"
|
||||||
|
className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500 ring-2 ring-background"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile section picker — collapses the sub-sidebar into a grouped `<Select>`. */
|
||||||
|
export function RoutineSectionPicker({
|
||||||
|
activeSection,
|
||||||
|
onNavigate,
|
||||||
|
isSectionDirty,
|
||||||
|
}: {
|
||||||
|
activeSection: RoutineSectionKey;
|
||||||
|
onNavigate: (section: RoutineSectionKey) => void;
|
||||||
|
isSectionDirty: (section: RoutineSectionKey) => boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-14 z-10 border-b border-border bg-background px-4 py-2 md:hidden">
|
||||||
|
<Select
|
||||||
|
value={activeSection}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (ROUTINE_SECTION_KEYS.includes(value as RoutineSectionKey)) {
|
||||||
|
onNavigate(value as RoutineSectionKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11 w-full" aria-label="Routine section">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<SelectGroup key={group.label}>
|
||||||
|
<SelectLabel className="uppercase tracking-[0.12em] text-[11px]">
|
||||||
|
{group.label}
|
||||||
|
</SelectLabel>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<SelectItem key={item.key} value={item.key} className="h-11">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<item.icon className="h-3.5 w-3.5" />
|
||||||
|
{item.label}
|
||||||
|
{isSectionDirty(item.key) ? (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ALL_ITEMS as ROUTINE_NAV_ITEMS };
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Clock3, RefreshCw, Save, Trash2, Webhook, Zap } from "lucide-react";
|
||||||
|
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ScheduleEditor } from "./ScheduleEditor";
|
||||||
|
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||||
|
import { describeCron } from "../lib/cron-readable";
|
||||||
|
|
||||||
|
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
||||||
|
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
||||||
|
|
||||||
|
function getLocalTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single trigger card with its own field-edit + save state (§3.2). Extracted
|
||||||
|
* from the previous inline `TriggerEditor` in `RoutineDetail.tsx` — same logic.
|
||||||
|
*/
|
||||||
|
export function RoutineTriggerCard({
|
||||||
|
trigger,
|
||||||
|
onSave,
|
||||||
|
onRotate,
|
||||||
|
onDelete,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
trigger: RoutineTrigger;
|
||||||
|
onSave: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
onRotate: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
label: trigger.label ?? "",
|
||||||
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft({
|
||||||
|
label: trigger.label ?? "",
|
||||||
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
|
});
|
||||||
|
}, [trigger]);
|
||||||
|
|
||||||
|
const KindIcon =
|
||||||
|
trigger.kind === "schedule" ? Clock3 : trigger.kind === "webhook" ? Webhook : Zap;
|
||||||
|
const humanCron = trigger.kind === "schedule" ? describeCron(draft.cronExpression) : null;
|
||||||
|
const lastResultOk =
|
||||||
|
trigger.lastResult != null &&
|
||||||
|
/succeed|success|ok|200|delivered/i.test(String(trigger.lastResult));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
aria-label={`Trigger: ${trigger.label ?? trigger.kind}`}
|
||||||
|
className="space-y-4 rounded-lg border border-border p-4"
|
||||||
|
onSubmit={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<KindIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{trigger.label ?? trigger.kind}</span>
|
||||||
|
</div>
|
||||||
|
{humanCron ? (
|
||||||
|
<p id={`cron-readable-${trigger.id}`} className="text-xs text-muted-foreground">
|
||||||
|
{humanCron}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{trigger.lastResult ? (
|
||||||
|
<Badge variant={lastResultOk ? "secondary" : "destructive"}>
|
||||||
|
{String(trigger.lastResult)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{trigger.kind === "schedule" && trigger.nextRunAt
|
||||||
|
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
||||||
|
: trigger.kind === "webhook"
|
||||||
|
? "Webhook"
|
||||||
|
: "API"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Label</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.label}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{trigger.kind === "schedule" && (
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<Label className="text-xs">Schedule</Label>
|
||||||
|
<ScheduleEditor
|
||||||
|
value={draft.cronExpression}
|
||||||
|
onChange={(cronExpression) =>
|
||||||
|
setDraft((current) => ({ ...current, cronExpression }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trigger.kind === "webhook" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Signing mode</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.signingMode}
|
||||||
|
onValueChange={(signingMode) =>
|
||||||
|
setDraft((current) => ({ ...current, signingMode }))
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{signingModes.map((mode) => (
|
||||||
|
<SelectItem key={mode} value={mode}>
|
||||||
|
{mode}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.replayWindowSec}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mr-auto text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(trigger.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
{trigger.kind === "webhook" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Rotate secret
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Save trigger
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
CompanySecret,
|
||||||
|
RoutineDetail as RoutineDetailType,
|
||||||
|
RoutineEnvConfig,
|
||||||
|
RoutineVariable,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import type { MarkdownEditorRef, MentionOption } from "../MarkdownEditor";
|
||||||
|
import type { InlineEntityOption } from "../InlineEntitySelector";
|
||||||
|
import type { RoutineHistoryDirtyFieldDescriptor } from "../RoutineHistoryTab";
|
||||||
|
import type { RoutineRunDialogSubmitData } from "../RoutineRunVariablesDialog";
|
||||||
|
import type {
|
||||||
|
RoutineTriggerResponse,
|
||||||
|
RotateRoutineTriggerResponse,
|
||||||
|
} from "../../api/routines";
|
||||||
|
import type { agentsApi } from "../../api/agents";
|
||||||
|
import type { projectsApi } from "../../api/projects";
|
||||||
|
import type { secretsApi } from "../../api/secrets";
|
||||||
|
|
||||||
|
export const ROUTINE_SECTION_KEYS = [
|
||||||
|
"overview",
|
||||||
|
"triggers",
|
||||||
|
"variables",
|
||||||
|
"secrets",
|
||||||
|
"delivery",
|
||||||
|
"runs",
|
||||||
|
"activity",
|
||||||
|
"history",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RoutineSectionKey = (typeof ROUTINE_SECTION_KEYS)[number];
|
||||||
|
|
||||||
|
/** Editable sections own a save bar; read-only (operate) sections do not. */
|
||||||
|
export const EDITABLE_SECTIONS: RoutineSectionKey[] = [
|
||||||
|
"overview",
|
||||||
|
"triggers",
|
||||||
|
"variables",
|
||||||
|
"secrets",
|
||||||
|
"delivery",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Which dirty-field keys belong to which section (for scoped save state). */
|
||||||
|
export const SECTION_FIELD_KEYS: Record<string, string[]> = {
|
||||||
|
overview: ["title", "description", "projectId", "assigneeAgentId", "priority"],
|
||||||
|
variables: ["variables"],
|
||||||
|
secrets: ["env"],
|
||||||
|
delivery: ["concurrencyPolicy", "catchUpPolicy"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoutineEditDraft = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
projectId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
priority: string;
|
||||||
|
concurrencyPolicy: string;
|
||||||
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
env: RoutineEnvConfig | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewTriggerDraft = {
|
||||||
|
kind: string;
|
||||||
|
cronExpression: string;
|
||||||
|
signingMode: string;
|
||||||
|
replayWindowSec: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecretMessage = {
|
||||||
|
title: string;
|
||||||
|
entries: Array<{ webhookUrl: string; webhookSecret: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentList = Awaited<ReturnType<typeof agentsApi.list>>;
|
||||||
|
type ProjectList = Awaited<ReturnType<typeof projectsApi.list>>;
|
||||||
|
type SecretList = Awaited<ReturnType<typeof secretsApi.list>>;
|
||||||
|
type RoutineRunList = Awaited<ReturnType<typeof import("../../api/routines").routinesApi.listRuns>>;
|
||||||
|
type RoutineActivityList = Awaited<
|
||||||
|
ReturnType<typeof import("../../api/routines").routinesApi.activity>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type RoutineDetailContextValue = {
|
||||||
|
routine: RoutineDetailType;
|
||||||
|
routineId: string;
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
// edit state
|
||||||
|
editDraft: RoutineEditDraft;
|
||||||
|
setEditDraft: React.Dispatch<React.SetStateAction<RoutineEditDraft>>;
|
||||||
|
routineDefaults: RoutineEditDraft;
|
||||||
|
dirtyFields: RoutineHistoryDirtyFieldDescriptor[];
|
||||||
|
isEditDirty: boolean;
|
||||||
|
sectionDirtyFields: (section: RoutineSectionKey) => RoutineHistoryDirtyFieldDescriptor[];
|
||||||
|
isSectionDirty: (section: RoutineSectionKey) => boolean;
|
||||||
|
discardSection: (section: RoutineSectionKey) => void;
|
||||||
|
|
||||||
|
// save
|
||||||
|
saveRoutine: UseMutationResult<unknown, unknown, void, unknown>;
|
||||||
|
saveConflict: boolean;
|
||||||
|
reloadLatest: () => void;
|
||||||
|
|
||||||
|
// header / automation
|
||||||
|
automationEnabled: boolean;
|
||||||
|
automationLabel: string;
|
||||||
|
automationLabelClassName: string;
|
||||||
|
automationToggleDisabled: boolean;
|
||||||
|
onToggleAutomation: () => void;
|
||||||
|
onOpenRunDialog: () => void;
|
||||||
|
runRoutinePending: boolean;
|
||||||
|
|
||||||
|
// triggers
|
||||||
|
newTrigger: NewTriggerDraft;
|
||||||
|
setNewTrigger: React.Dispatch<React.SetStateAction<NewTriggerDraft>>;
|
||||||
|
createTrigger: UseMutationResult<RoutineTriggerResponse, unknown, void, unknown>;
|
||||||
|
updateTrigger: UseMutationResult<unknown, unknown, { id: string; patch: Record<string, unknown> }, unknown>;
|
||||||
|
deleteTrigger: UseMutationResult<unknown, unknown, string, unknown>;
|
||||||
|
rotateTrigger: UseMutationResult<RotateRoutineTriggerResponse, unknown, string, unknown>;
|
||||||
|
|
||||||
|
// secrets
|
||||||
|
secretMessage: SecretMessage | null;
|
||||||
|
setSecretMessage: (message: SecretMessage | null) => void;
|
||||||
|
copySecretValue: (label: string, value: string) => void;
|
||||||
|
availableSecrets: SecretList;
|
||||||
|
createSecret: UseMutationResult<CompanySecret, unknown, { name: string; value: string }, unknown>;
|
||||||
|
|
||||||
|
// entities
|
||||||
|
agents: AgentList;
|
||||||
|
projects: ProjectList;
|
||||||
|
agentById: Map<string, AgentList[number]>;
|
||||||
|
projectById: Map<string, ProjectList[number]>;
|
||||||
|
assigneeOptions: InlineEntityOption[];
|
||||||
|
projectOptions: InlineEntityOption[];
|
||||||
|
recentAssigneeIds: string[];
|
||||||
|
recentProjectIds: string[];
|
||||||
|
mentionOptions: MentionOption[];
|
||||||
|
currentAssignee: AgentList[number] | null;
|
||||||
|
currentProject: ProjectList[number] | null;
|
||||||
|
|
||||||
|
// operate data
|
||||||
|
routineRuns: RoutineRunList | undefined;
|
||||||
|
activity: RoutineActivityList | undefined;
|
||||||
|
hasLiveRun: boolean;
|
||||||
|
activeIssueId: string | undefined;
|
||||||
|
|
||||||
|
// refs
|
||||||
|
titleInputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
|
descriptionEditorRef: React.RefObject<MarkdownEditorRef | null>;
|
||||||
|
assigneeSelectorRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
projectSelectorRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
|
||||||
|
// history restore plumbing
|
||||||
|
onHistoryRestoreSecretMaterials: (
|
||||||
|
response: import("../../api/routines").RestoreRoutineRevisionResponse,
|
||||||
|
) => void;
|
||||||
|
onHistoryRestored: (
|
||||||
|
response: import("../../api/routines").RestoreRoutineRevisionResponse,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
navigateToSection: (section: RoutineSectionKey, options?: { replace?: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoutineDetailContext = createContext<RoutineDetailContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useRoutineDetail(): RoutineDetailContextValue {
|
||||||
|
const value = useContext(RoutineDetailContext);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("useRoutineDetail must be used within a RoutineDetailContext provider");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Braces,
|
||||||
|
Clock3,
|
||||||
|
Edit3,
|
||||||
|
KeyRound,
|
||||||
|
Play,
|
||||||
|
} 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 { timeAgo } from "../../lib/timeAgo";
|
||||||
|
import { EmptyState } from "../EmptyState";
|
||||||
|
import { InlineEntitySelector } from "../InlineEntitySelector";
|
||||||
|
import { AgentIcon } from "../AgentIconPicker";
|
||||||
|
import { MarkdownEditor } from "../MarkdownEditor";
|
||||||
|
import { ScheduleEditor } from "../ScheduleEditor";
|
||||||
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
|
||||||
|
import { RoutineTriggerCard } from "../RoutineTriggerCard";
|
||||||
|
import { EnvVarEditor } from "../EnvVarEditor";
|
||||||
|
import { useRoutineDetail } from "./context";
|
||||||
|
import type { EnvBinding } 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 in capped batches after recovery.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const ctx = useRoutineDetail();
|
||||||
|
const {
|
||||||
|
routine,
|
||||||
|
editDraft,
|
||||||
|
setEditDraft,
|
||||||
|
assigneeOptions,
|
||||||
|
projectOptions,
|
||||||
|
recentAssigneeIds,
|
||||||
|
recentProjectIds,
|
||||||
|
agentById,
|
||||||
|
projectById,
|
||||||
|
currentAssignee,
|
||||||
|
currentProject,
|
||||||
|
mentionOptions,
|
||||||
|
assigneeSelectorRef,
|
||||||
|
projectSelectorRef,
|
||||||
|
descriptionEditorRef,
|
||||||
|
routineRuns,
|
||||||
|
activity,
|
||||||
|
saveRoutine,
|
||||||
|
navigateToSection,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
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="Assignee"
|
||||||
|
noneLabel="No assignee"
|
||||||
|
searchPlaceholder="Search assignees..."
|
||||||
|
emptyMessage="No assignees 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">Assignee</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 ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<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 ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<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 */}
|
||||||
|
<MarkdownEditor
|
||||||
|
ref={descriptionEditorRef}
|
||||||
|
value={editDraft.description}
|
||||||
|
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
||||||
|
placeholder="Add instructions..."
|
||||||
|
bordered={false}
|
||||||
|
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||||
|
mentions={mentionOptions}
|
||||||
|
onSubmit={() => {
|
||||||
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||||
|
saveRoutine.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Add trigger form */}
|
||||||
|
<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 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
||||||
|
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing triggers */}
|
||||||
|
{routine.triggers.length === 0 ? (
|
||||||
|
<EmptyState icon={Clock3} message="No triggers yet." />
|
||||||
|
) : (
|
||||||
|
<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;
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
<EnvVarEditor
|
||||||
|
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
|
||||||
|
secrets={availableSecrets}
|
||||||
|
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 } = ctx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.18em] 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-[0.18em] text-muted-foreground">
|
||||||
|
Catch-up
|
||||||
|
</p>
|
||||||
|
<RadioCardGroup
|
||||||
|
ariaLabel="Catch-up policy"
|
||||||
|
value={editDraft.catchUpPolicy}
|
||||||
|
onValueChange={(catchUpPolicy) =>
|
||||||
|
setEditDraft((current) => ({ ...current, catchUpPolicy }))
|
||||||
|
}
|
||||||
|
options={catchUpPolicyOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Activity as ActivityIcon, Play } from "lucide-react";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { timeAgo } from "../../lib/timeAgo";
|
||||||
|
import { EmptyState } from "../EmptyState";
|
||||||
|
import { LiveRunWidget } from "../LiveRunWidget";
|
||||||
|
import { RoutineHistoryTab } from "../RoutineHistoryTab";
|
||||||
|
import { RoutineActivityRow } from "../RoutineActivityRow";
|
||||||
|
import { useRoutineDetail } from "./context";
|
||||||
|
|
||||||
|
export function RunsSection() {
|
||||||
|
const ctx = useRoutineDetail();
|
||||||
|
const { routine, routineRuns, hasLiveRun, activeIssueId, onOpenRunDialog } = ctx;
|
||||||
|
const runs = routineRuns ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{hasLiveRun && activeIssueId ? (
|
||||||
|
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||||
|
) : null}
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Play}
|
||||||
|
message="No runs yet. Trigger a run from the header or wait for the schedule."
|
||||||
|
action="Run now"
|
||||||
|
onAction={onOpenRunDialog}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border rounded-lg border border-border">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{run.source}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={run.status === "failed" ? "destructive" : "secondary"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{run.status.replaceAll("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
{run.trigger ? (
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{run.trigger.label ?? run.trigger.kind}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{run.linkedIssue ? (
|
||||||
|
<Link
|
||||||
|
to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`}
|
||||||
|
className="truncate text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
|
||||||
|
{timeAgo(run.triggeredAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivitySection() {
|
||||||
|
const ctx = useRoutineDetail();
|
||||||
|
const { activity } = ctx;
|
||||||
|
const events = activity ?? [];
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byDay = new Map<string, typeof events>();
|
||||||
|
for (const event of events) {
|
||||||
|
let label = "Earlier";
|
||||||
|
try {
|
||||||
|
label = new Date(event.createdAt).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* keep fallback label */
|
||||||
|
}
|
||||||
|
const bucket = byDay.get(label) ?? [];
|
||||||
|
bucket.push(event);
|
||||||
|
byDay.set(label, bucket);
|
||||||
|
}
|
||||||
|
return Array.from(byDay.entries());
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <EmptyState icon={ActivityIcon} message="No activity yet." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{groups.map(([day, dayEvents]) => (
|
||||||
|
<div key={day}>
|
||||||
|
<div className="sticky top-0 bg-background py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{dayEvents.map((event) => (
|
||||||
|
<RoutineActivityRow key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistorySection() {
|
||||||
|
const ctx = useRoutineDetail();
|
||||||
|
const {
|
||||||
|
routine,
|
||||||
|
isEditDirty,
|
||||||
|
dirtyFields,
|
||||||
|
routineDefaults,
|
||||||
|
setEditDraft,
|
||||||
|
saveRoutine,
|
||||||
|
agentById,
|
||||||
|
projectById,
|
||||||
|
availableSecrets,
|
||||||
|
onHistoryRestoreSecretMaterials,
|
||||||
|
onHistoryRestored,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoutineHistoryTab
|
||||||
|
routine={routine}
|
||||||
|
isEditDirty={isEditDirty}
|
||||||
|
dirtyFields={dirtyFields}
|
||||||
|
onDiscardEdits={() => setEditDraft(routineDefaults)}
|
||||||
|
onSaveEdits={() => {
|
||||||
|
if (!saveRoutine.isPending && routine.title.trim()) {
|
||||||
|
saveRoutine.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
agents={agentById}
|
||||||
|
projects={projectById}
|
||||||
|
secrets={availableSecrets}
|
||||||
|
onRestoreSecretMaterials={onHistoryRestoreSecretMaterials}
|
||||||
|
onRestored={onHistoryRestored}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type RadioCardOption = {
|
||||||
|
value: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectable card primitive — a labelled `<button aria-pressed>` with a
|
||||||
|
* ring-on-selected treatment. Used by the routine Delivery section (§3.5) and
|
||||||
|
* reusable for onboarding / adapter pickers. Render several inside a
|
||||||
|
* `<RadioCardGroup>` for roving keyboard nav.
|
||||||
|
*/
|
||||||
|
export function RadioCard({
|
||||||
|
selected,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
} & Omit<React.ComponentProps<"button">, "title">) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
data-state={selected ? "checked" : "unchecked"}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex w-full flex-col items-start gap-1 rounded-md border px-4 py-3 text-left transition-colors",
|
||||||
|
"motion-safe:transition-[border-color,background-color] motion-safe:duration-150",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
|
: "border-border hover:border-border hover:bg-accent/40",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
{selected ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
|
||||||
|
</div>
|
||||||
|
{description ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioCardGroup({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
options,
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
options: RadioCardOption[];
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const idx = options.findIndex((option) => option.value === value);
|
||||||
|
if (idx === -1) return;
|
||||||
|
let nextIdx: number | null = null;
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
nextIdx = (idx + 1) % options.length;
|
||||||
|
} else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
nextIdx = (idx - 1 + options.length) % options.length;
|
||||||
|
}
|
||||||
|
if (nextIdx !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
onValueChange(options[nextIdx].value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<RadioCard
|
||||||
|
key={option.value}
|
||||||
|
selected={option.value === value}
|
||||||
|
title={option.title}
|
||||||
|
description={option.description}
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={option.value === value ? 0 : -1}
|
||||||
|
onClick={() => onValueChange(option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { describeCron } from "./cron-readable";
|
||||||
|
|
||||||
|
describe("describeCron", () => {
|
||||||
|
it("describes a daily time", () => {
|
||||||
|
expect(describeCron("0 14 * * *")).toBe("Every day at 14:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes every-weekday", () => {
|
||||||
|
expect(describeCron("0 14 * * 1-5")).toBe("Every weekday at 14:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes a single day-of-week", () => {
|
||||||
|
expect(describeCron("30 9 * * 1")).toBe("Every Monday at 09:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes every N minutes", () => {
|
||||||
|
expect(describeCron("*/15 * * * *")).toBe("Every 15 minutes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes hourly on a minute", () => {
|
||||||
|
expect(describeCron("5 * * * *")).toBe("Every hour at :05");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty or unparseable input", () => {
|
||||||
|
expect(describeCron("")).toBeNull();
|
||||||
|
expect(describeCron(undefined)).toBeNull();
|
||||||
|
expect(describeCron("not a cron")).toBeNull();
|
||||||
|
expect(describeCron("1 2 3")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Tiny best-effort cron → plain-English helper for the routine Triggers section.
|
||||||
|
* Not a full cron parser: it covers the common shapes Paperclip schedule triggers
|
||||||
|
* produce (every N minutes/hours, daily at HH:MM, weekday/weekend, day-of-week).
|
||||||
|
* Falls back to the raw expression when it can't confidently describe it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
function pad2(value: number): string {
|
||||||
|
return value.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTime(minute: string, hour: string): string | null {
|
||||||
|
const m = Number(minute);
|
||||||
|
const h = Number(hour);
|
||||||
|
if (!Number.isInteger(m) || !Number.isInteger(h)) return null;
|
||||||
|
if (m < 0 || m > 59 || h < 0 || h > 23) return null;
|
||||||
|
return `${pad2(h)}:${pad2(m)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDayOfWeek(dow: string): string | null {
|
||||||
|
if (dow === "*" || dow === "?") return "every day";
|
||||||
|
if (dow === "1-5") return "every weekday";
|
||||||
|
if (dow === "0,6" || dow === "6,0" || dow === "0,7") return "every weekend";
|
||||||
|
const parts = dow.split(",").map((part) => part.trim());
|
||||||
|
const names = parts.map((part) => {
|
||||||
|
const n = Number(part);
|
||||||
|
if (!Number.isInteger(n)) return null;
|
||||||
|
return DOW_NAMES[n % 7];
|
||||||
|
});
|
||||||
|
if (names.some((name) => name === null)) return null;
|
||||||
|
if (names.length === 1) return `every ${names[0]}`;
|
||||||
|
return `every ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeCron(expression: string | null | undefined): string | null {
|
||||||
|
if (!expression) return null;
|
||||||
|
const trimmed = expression.trim();
|
||||||
|
const fields = trimmed.split(/\s+/);
|
||||||
|
// Standard 5-field cron: minute hour day-of-month month day-of-week
|
||||||
|
if (fields.length !== 5) return null;
|
||||||
|
const [minute, hour, dom, month, dow] = fields;
|
||||||
|
|
||||||
|
// Every N minutes
|
||||||
|
const everyMinutes = minute.match(/^\*\/(\d+)$/);
|
||||||
|
if (everyMinutes && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
||||||
|
return `Every ${everyMinutes[1]} minutes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every N hours, on the minute
|
||||||
|
const everyHours = hour.match(/^\*\/(\d+)$/);
|
||||||
|
if (everyHours && /^\d+$/.test(minute) && dom === "*" && month === "*" && dow === "*") {
|
||||||
|
return `Every ${everyHours[1]} hours at :${pad2(Number(minute))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hourly
|
||||||
|
if (/^\d+$/.test(minute) && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
||||||
|
return `Every hour at :${pad2(Number(minute))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily / weekly at a fixed time
|
||||||
|
if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && month === "*") {
|
||||||
|
const time = describeTime(minute, hour);
|
||||||
|
if (!time) return null;
|
||||||
|
if (dom === "*" && (dow === "*" || dow === "?")) {
|
||||||
|
return `Every day at ${time}`;
|
||||||
|
}
|
||||||
|
if (dom === "*") {
|
||||||
|
const dowText = describeDayOfWeek(dow);
|
||||||
|
if (dowText) return `${dowText[0].toUpperCase()}${dowText.slice(1)} at ${time}`;
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(dom) && (dow === "*" || dow === "?")) {
|
||||||
|
return `Day ${dom} of every month at ${time}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+441
-897
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
RoutineDetail as RoutineDetailType,
|
||||||
|
RoutineTrigger,
|
||||||
|
RoutineVariable,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RunButton } from "@/components/AgentActionButtons";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
|
import { RoutineSubSidebar, RoutineSectionPicker } from "@/components/RoutineSubSidebar";
|
||||||
|
import { RoutineSaveBar } from "@/components/RoutineSaveBar";
|
||||||
|
import {
|
||||||
|
EDITABLE_SECTIONS,
|
||||||
|
RoutineDetailContext,
|
||||||
|
type RoutineDetailContextValue,
|
||||||
|
type RoutineEditDraft,
|
||||||
|
type RoutineSectionKey,
|
||||||
|
} from "@/components/routine-sections/context";
|
||||||
|
import {
|
||||||
|
OverviewSection,
|
||||||
|
TriggersSection,
|
||||||
|
VariablesSection,
|
||||||
|
SecretsSection,
|
||||||
|
DeliverySection,
|
||||||
|
} from "@/components/routine-sections/editable-sections";
|
||||||
|
import {
|
||||||
|
RunsSection,
|
||||||
|
ActivitySection,
|
||||||
|
} from "@/components/routine-sections/operate-sections";
|
||||||
|
import { storybookAgents, storybookProjects } from "../fixtures/paperclipData";
|
||||||
|
|
||||||
|
const COMPANY_ID = "company-storybook";
|
||||||
|
const ROUTINE_ID = "routine-storybook";
|
||||||
|
|
||||||
|
const now = new Date("2026-06-09T12:00:00Z");
|
||||||
|
|
||||||
|
const variables: RoutineVariable[] = [
|
||||||
|
{ name: "customer_name", label: "Customer name", type: "text", defaultValue: "Acme", required: true, options: [] },
|
||||||
|
{ name: "deadline", label: "Deadline", type: "text", defaultValue: null, required: false, options: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const triggers: RoutineTrigger[] = [
|
||||||
|
{
|
||||||
|
id: "trigger-schedule",
|
||||||
|
companyId: COMPANY_ID,
|
||||||
|
routineId: ROUTINE_ID,
|
||||||
|
kind: "schedule",
|
||||||
|
label: "schedule",
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: "0 14 * * 1-5",
|
||||||
|
timezone: "UTC",
|
||||||
|
nextRunAt: new Date("2026-06-09T14:00:00Z"),
|
||||||
|
lastFiredAt: new Date("2026-06-08T14:00:00Z"),
|
||||||
|
publicId: null,
|
||||||
|
secretId: null,
|
||||||
|
signingMode: null,
|
||||||
|
replayWindowSec: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: "succeeded",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const routine: RoutineDetailType = {
|
||||||
|
id: ROUTINE_ID,
|
||||||
|
companyId: COMPANY_ID,
|
||||||
|
projectId: storybookProjects[0]?.id ?? null,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "Send the weekly digest to {{customer_name}}",
|
||||||
|
description:
|
||||||
|
"Compile last week's shipped work and email a digest to {{customer_name}} by {{deadline}}.\n\nKeep it to five bullets.",
|
||||||
|
assigneeAgentId: storybookAgents[0]?.id ?? null,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables,
|
||||||
|
env: { DATABASE_URL: { kind: "secret", secretId: "secret-prod-db", version: "latest" } } as never,
|
||||||
|
latestRevisionId: "rev-17",
|
||||||
|
latestRevisionNumber: 17,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: new Date("2026-06-08T14:00:00Z"),
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
managedByPlugin: null,
|
||||||
|
project: null,
|
||||||
|
assignee: null,
|
||||||
|
parentIssue: null,
|
||||||
|
triggers,
|
||||||
|
recentRuns: [],
|
||||||
|
activeIssue: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const routineRuns = [
|
||||||
|
{ id: "run-1", source: "manual", status: "succeeded", triggeredAt: new Date("2026-06-09T11:48:00Z"), trigger: { label: "manual", kind: "manual" }, linkedIssue: { id: "issue-1", identifier: "PAP-99221" } },
|
||||||
|
{ id: "run-2", source: "schedule", status: "failed", triggeredAt: new Date("2026-06-08T14:00:00Z"), trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-2", identifier: "PAP-99220" } },
|
||||||
|
{ id: "run-3", source: "schedule", status: "succeeded", triggeredAt: new Date("2026-06-07T14:00:00Z"), trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-3", identifier: "PAP-99219" } },
|
||||||
|
] as never;
|
||||||
|
|
||||||
|
const activity = [
|
||||||
|
{ id: "act-1", action: "trigger.fired", details: { trigger: "schedule", run: "PAP-99221" }, createdAt: new Date("2026-06-09T14:02:00Z") },
|
||||||
|
{ id: "act-2", action: "routine.updated", details: { fields: ["instructions", "variables"] }, createdAt: new Date("2026-06-09T13:55:00Z") },
|
||||||
|
{ id: "act-3", action: "run.completed", details: { issue: "PAP-99220", status: "failed" }, createdAt: new Date("2026-06-08T23:01:00Z") },
|
||||||
|
] as never;
|
||||||
|
|
||||||
|
function stubMutation(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
isPending: false,
|
||||||
|
mutate: () => {},
|
||||||
|
mutateAsync: async () => ({}),
|
||||||
|
...overrides,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(dirty: boolean, navigate: (s: RoutineSectionKey) => void): RoutineDetailContextValue {
|
||||||
|
const defaults: RoutineEditDraft = {
|
||||||
|
title: routine.title,
|
||||||
|
description: routine.description ?? "",
|
||||||
|
projectId: routine.projectId ?? "",
|
||||||
|
assigneeAgentId: routine.assigneeAgentId ?? "",
|
||||||
|
priority: routine.priority,
|
||||||
|
concurrencyPolicy: routine.concurrencyPolicy,
|
||||||
|
catchUpPolicy: routine.catchUpPolicy,
|
||||||
|
variables: routine.variables,
|
||||||
|
env: routine.env ?? null,
|
||||||
|
};
|
||||||
|
const editDraft: RoutineEditDraft = dirty
|
||||||
|
? { ...defaults, description: `${defaults.description}\n\nAlways CC the account owner.` }
|
||||||
|
: defaults;
|
||||||
|
const dirtyFields = dirty ? [{ key: "description", label: "the description" }] : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
routine,
|
||||||
|
routineId: ROUTINE_ID,
|
||||||
|
companyId: COMPANY_ID,
|
||||||
|
editDraft,
|
||||||
|
setEditDraft: () => {},
|
||||||
|
routineDefaults: defaults,
|
||||||
|
dirtyFields,
|
||||||
|
isEditDirty: dirty,
|
||||||
|
sectionDirtyFields: (s) => (s === "overview" ? dirtyFields : []),
|
||||||
|
isSectionDirty: (s) => dirty && s === "overview",
|
||||||
|
discardSection: () => {},
|
||||||
|
saveRoutine: stubMutation(),
|
||||||
|
saveConflict: false,
|
||||||
|
reloadLatest: () => {},
|
||||||
|
automationEnabled: true,
|
||||||
|
automationLabel: "Active",
|
||||||
|
automationLabelClassName: "text-emerald-400",
|
||||||
|
automationToggleDisabled: false,
|
||||||
|
onToggleAutomation: () => {},
|
||||||
|
onOpenRunDialog: () => {},
|
||||||
|
runRoutinePending: false,
|
||||||
|
newTrigger: { kind: "schedule", cronExpression: "0 14 * * 1-5", signingMode: "bearer", replayWindowSec: "300" },
|
||||||
|
setNewTrigger: () => {},
|
||||||
|
createTrigger: stubMutation(),
|
||||||
|
updateTrigger: stubMutation(),
|
||||||
|
deleteTrigger: stubMutation(),
|
||||||
|
rotateTrigger: stubMutation(),
|
||||||
|
secretMessage: null,
|
||||||
|
setSecretMessage: () => {},
|
||||||
|
copySecretValue: () => {},
|
||||||
|
availableSecrets: [],
|
||||||
|
createSecret: stubMutation(),
|
||||||
|
agents: storybookAgents,
|
||||||
|
projects: storybookProjects,
|
||||||
|
agentById: new Map(storybookAgents.map((a) => [a.id, a])),
|
||||||
|
projectById: new Map(storybookProjects.map((p) => [p.id, p])),
|
||||||
|
assigneeOptions: storybookAgents.map((a) => ({ id: a.id, label: a.name, searchText: a.name })),
|
||||||
|
projectOptions: storybookProjects.map((p) => ({ id: p.id, label: p.name, searchText: p.name })),
|
||||||
|
recentAssigneeIds: [],
|
||||||
|
recentProjectIds: [],
|
||||||
|
mentionOptions: [],
|
||||||
|
currentAssignee: storybookAgents[0] ?? null,
|
||||||
|
currentProject: storybookProjects[0] ?? null,
|
||||||
|
routineRuns,
|
||||||
|
activity,
|
||||||
|
hasLiveRun: false,
|
||||||
|
activeIssueId: undefined,
|
||||||
|
titleInputRef: { current: null },
|
||||||
|
descriptionEditorRef: { current: null },
|
||||||
|
assigneeSelectorRef: { current: null },
|
||||||
|
projectSelectorRef: { current: null },
|
||||||
|
onHistoryRestoreSecretMaterials: () => {},
|
||||||
|
onHistoryRestored: () => {},
|
||||||
|
navigateToSection: navigate,
|
||||||
|
} as RoutineDetailContextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_TITLES: Record<RoutineSectionKey, string> = {
|
||||||
|
overview: "Overview",
|
||||||
|
triggers: "Triggers",
|
||||||
|
variables: "Variables",
|
||||||
|
secrets: "Secrets",
|
||||||
|
delivery: "Delivery",
|
||||||
|
runs: "Runs",
|
||||||
|
activity: "Activity",
|
||||||
|
history: "History",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionBody({ section }: { section: RoutineSectionKey }) {
|
||||||
|
switch (section) {
|
||||||
|
case "overview":
|
||||||
|
return <OverviewSection />;
|
||||||
|
case "triggers":
|
||||||
|
return <TriggersSection />;
|
||||||
|
case "variables":
|
||||||
|
return <VariablesSection />;
|
||||||
|
case "secrets":
|
||||||
|
return <SecretsSection />;
|
||||||
|
case "delivery":
|
||||||
|
return <DeliverySection />;
|
||||||
|
case "runs":
|
||||||
|
return <RunsSection />;
|
||||||
|
case "activity":
|
||||||
|
return <ActivitySection />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutineCShell({ initialSection = "overview", dirty = false }: { initialSection?: RoutineSectionKey; dirty?: boolean }) {
|
||||||
|
const [section, setSection] = useState<RoutineSectionKey>(initialSection);
|
||||||
|
const ctx = useMemo(() => makeContext(dirty, setSection), [dirty]);
|
||||||
|
const isEditable = EDITABLE_SECTIONS.includes(section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoutineDetailContext.Provider value={ctx}>
|
||||||
|
<div className="flex h-[900px] flex-col bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-20 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">
|
||||||
|
<span className="truncate text-base font-semibold">{routine.title}</span>
|
||||||
|
<Badge variant="outline" className="hidden shrink-0 gap-1.5 text-xs text-muted-foreground sm:inline-flex">
|
||||||
|
<Sparkles className="h-3 w-3" /> Digest Bot
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-3">
|
||||||
|
<RunButton onClick={() => {}} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ToggleSwitch size="default" checked onCheckedChange={() => {}} aria-label="Toggle automation" />
|
||||||
|
<span className="text-sm font-medium text-emerald-400">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RoutineSectionPicker activeSection={section} onNavigate={setSection} isSectionDirty={ctx.isSectionDirty} />
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<RoutineSubSidebar
|
||||||
|
activeSection={section}
|
||||||
|
hrefFor={(s) => `#${s}`}
|
||||||
|
isSectionDirty={ctx.isSectionDirty}
|
||||||
|
hasLiveRun={false}
|
||||||
|
onNavigate={setSection}
|
||||||
|
/>
|
||||||
|
<main className="min-w-0 flex-1 overflow-y-auto px-4 py-6 md:px-8">
|
||||||
|
<section className={isEditable ? "mx-auto w-full max-w-3xl" : "w-full"}>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">{SECTION_TITLES[section]}</h2>
|
||||||
|
<SectionBody section={section} />
|
||||||
|
{isEditable ? (
|
||||||
|
<RoutineSaveBar
|
||||||
|
dirtyFields={ctx.sectionDirtyFields(section)}
|
||||||
|
isSaving={false}
|
||||||
|
saveConflict={false}
|
||||||
|
onSave={() => {}}
|
||||||
|
onDiscard={() => {}}
|
||||||
|
onReload={() => {}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RoutineDetailContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof RoutineCShell> = {
|
||||||
|
title: "Product/Routines · Detail (variation C)",
|
||||||
|
component: RoutineCShell,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen",
|
||||||
|
a11y: { test: "off" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof RoutineCShell>;
|
||||||
|
|
||||||
|
export const Overview: Story = { args: { initialSection: "overview", dirty: true } };
|
||||||
|
export const Triggers: Story = { args: { initialSection: "triggers" } };
|
||||||
|
export const Variables: Story = { args: { initialSection: "variables" } };
|
||||||
|
export const Secrets: Story = { args: { initialSection: "secrets" } };
|
||||||
|
export const Delivery: Story = { args: { initialSection: "delivery" } };
|
||||||
|
export const Runs: Story = { args: { initialSection: "runs" } };
|
||||||
|
export const Activity: Story = { args: { initialSection: "activity" } };
|
||||||
Reference in New Issue
Block a user