fae7e920a9
## 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>
342 lines
12 KiB
TypeScript
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" } };
|