Files
paperclip/ui/storybook/stories/routine-detail-c.stories.tsx
T
Dotta fae7e920a9 [codex] Polish routine layout follow-ups (#7858)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Routines are the recurring-work surface that lets a company keep
operating without a human manually kicking off every task
> - The base routine detail Variation C shell already landed in #7848,
but the follow-up branch still had polish work for scheduling, section
ergonomics, and the list layout
> - Operators need routine edit screens to explain trigger behavior
clearly, keep long detail pages usable on mobile/touch devices, and make
grouped routine lists easier to scan
> - This pull request rebases the remaining branch work onto current
`master`, drops the duplicate commits already merged through #7848, and
keeps only the new routine UI follow-ups
> - The benefit is a cleaner routines workflow without reopening the
already-merged shell work or carrying unrelated lockfile, workflow, or
screenshot changes

## Linked Issues or Issue Description

Refs #7848

Feature follow-up: polish the routines UI after the Variation C
routine-detail shell landed.

Problem / motivation:

- Routine trigger configuration needs clearer previews for manual,
schedule, API, and webhook execution modes.
- Routine detail sections need better responsive spacing and touch
ergonomics.
- The routines list grouping should scan like grouped records instead of
a table with heavy row dividers.
- The routine tests need a React 19-compatible render helper so the
focused routine suite can run in this workspace.

Proposed solution:

- Add cron-fire preview helpers and routine-run display helpers with
focused tests.
- Expand the routine editable and operate sections with richer trigger,
variable, run, activity, and history presentation.
- Adjust the routine detail shell and sub-sidebar spacing for
mobile/touch layout.
- Update grouped routine list presentation to use bordered group headers
with borderless rows.
- Switch affected routine tests to the repo's `flushSync` render-helper
pattern.

Alternatives considered:

- Leaving the duplicate pre-#7848 commits in the branch would recreate
conflicts and make the PR review much larger than the remaining change.
- Keeping grouped routine rows inside one bordered table was simpler,
but made the grouping hierarchy less legible.

Roadmap alignment:

- ROADMAP.md lists Scheduled Routines as a core shipped capability and
Output/Enforced Outcomes as ongoing priorities. This is polish on that
existing routines capability, not a new roadmap-level feature.

## What Changed

- Added routine scheduling preview helpers and tests for
cron/manual/API/webhook fire-policy display.
- Added routine run display helpers and tests for deduped trigger labels
and run-row subtitles.
- Polished routine detail sections, including trigger summaries, operate
views, and env/variable editing ergonomics.
- Adjusted routine detail page and sub-sidebar spacing so the
title/header area is less pinned and touch layouts center better.
- Reworked the routines list grouped layout so group headers are
bordered cards and routine rows are borderless inside each group.
- Added Storybook coverage for the routines list grouped layout and
updated the existing routine detail story.
- Repaired routine tests to use `flushSync` helpers compatible with the
installed React 19 runtime.

## Verification

- `pnpm exec vitest run ui/src/lib/cron-fires.test.ts
ui/src/lib/routine-run-display.test.ts ui/src/pages/Routines.test.tsx
ui/src/components/RoutineSubSidebar.test.tsx
ui/src/components/RoutineSaveBar.test.tsx`
  - Result: 5 test files passed, 37 tests passed.
- Confirmed the rebased PR diff does not include `pnpm-lock.yaml`,
`.github/workflows/*`, or committed screenshots.
- Confirmed `origin/master` is an ancestor of the pushed branch head
after rebase.

## Risks

- Medium UI risk: this touches the routine detail and routine list
surfaces, so visual regressions are possible in edge cases not covered
by the focused tests.
- Low data risk: no schema, migration, server API, or lockfile changes
are included.
- Review note: the branch intentionally force-pushed after rebasing
because the original first three commits were already merged through
#7848.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent runtime, with repository
shell/tool access. Exact hosted runtime model identifier and
context-window size were not exposed in the execution environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:55:01 -05:00

342 lines
12 KiB
TypeScript

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: { type: "secret_ref", 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"), failureReason: null, triggerPayload: { customer_name: "Acme", deadline: "Fri" }, trigger: { label: "manual", kind: "manual" }, linkedIssue: { id: "issue-1", identifier: "PAP-99221", title: "Weekly digest for Acme" } },
{ id: "run-2", source: "schedule", status: "failed", triggeredAt: new Date("2026-06-08T14:00:00Z"), failureReason: "Cron timed out after 600s", triggerPayload: { customer_name: "Acme" }, trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-2", identifier: "PAP-99220", title: "Weekly digest for Acme" } },
{ id: "run-3", source: "schedule", status: "succeeded", triggeredAt: new Date("2026-06-07T14:00:00Z"), failureReason: null, triggerPayload: { customer_name: "Globex" }, trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-3", identifier: "PAP-99219", title: "Weekly digest for Globex" } },
] as never;
function stubSecret(id: string, name: string, latestVersion: number, referenceCount: number) {
return {
id,
companyId: COMPANY_ID,
key: name.toLowerCase(),
name,
provider: "paperclip",
status: "active",
managedMode: "managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion,
description: null,
lastResolvedAt: now,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
referenceCount,
createdAt: now,
updatedAt: now,
};
}
const availableSecrets = [
stubSecret("secret-prod-db", "prod-db", 3, 5),
stubSecret("secret-gh-token", "gh-token", 2, 4),
stubSecret("secret-openai", "openai-key", 1, 2),
stubSecret("secret-stripe", "stripe-key", 1, 1),
] 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 overflow-y-auto bg-background text-foreground">
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<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 px-4 pb-6 pt-10 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" } };