bf62e3fbf1
## 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>
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
}
|