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:
@@ -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