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:
Dotta
2026-06-09 17:04:25 -05:00
committed by GitHub
parent a0f7d3daba
commit bf62e3fbf1
15 changed files with 2743 additions and 897 deletions
@@ -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" } };