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,171 @@
import { createContext, useContext } from "react";
import type { UseMutationResult } from "@tanstack/react-query";
import type {
CompanySecret,
RoutineDetail as RoutineDetailType,
RoutineEnvConfig,
RoutineVariable,
} from "@paperclipai/shared";
import type { MarkdownEditorRef, MentionOption } from "../MarkdownEditor";
import type { InlineEntityOption } from "../InlineEntitySelector";
import type { RoutineHistoryDirtyFieldDescriptor } from "../RoutineHistoryTab";
import type { RoutineRunDialogSubmitData } from "../RoutineRunVariablesDialog";
import type {
RoutineTriggerResponse,
RotateRoutineTriggerResponse,
} from "../../api/routines";
import type { agentsApi } from "../../api/agents";
import type { projectsApi } from "../../api/projects";
import type { secretsApi } from "../../api/secrets";
export const ROUTINE_SECTION_KEYS = [
"overview",
"triggers",
"variables",
"secrets",
"delivery",
"runs",
"activity",
"history",
] as const;
export type RoutineSectionKey = (typeof ROUTINE_SECTION_KEYS)[number];
/** Editable sections own a save bar; read-only (operate) sections do not. */
export const EDITABLE_SECTIONS: RoutineSectionKey[] = [
"overview",
"triggers",
"variables",
"secrets",
"delivery",
];
/** Which dirty-field keys belong to which section (for scoped save state). */
export const SECTION_FIELD_KEYS: Record<string, string[]> = {
overview: ["title", "description", "projectId", "assigneeAgentId", "priority"],
variables: ["variables"],
secrets: ["env"],
delivery: ["concurrencyPolicy", "catchUpPolicy"],
};
export type RoutineEditDraft = {
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
};
export type NewTriggerDraft = {
kind: string;
cronExpression: string;
signingMode: string;
replayWindowSec: string;
};
export type SecretMessage = {
title: string;
entries: Array<{ webhookUrl: string; webhookSecret: string }>;
};
type AgentList = Awaited<ReturnType<typeof agentsApi.list>>;
type ProjectList = Awaited<ReturnType<typeof projectsApi.list>>;
type SecretList = Awaited<ReturnType<typeof secretsApi.list>>;
type RoutineRunList = Awaited<ReturnType<typeof import("../../api/routines").routinesApi.listRuns>>;
type RoutineActivityList = Awaited<
ReturnType<typeof import("../../api/routines").routinesApi.activity>
>;
export type RoutineDetailContextValue = {
routine: RoutineDetailType;
routineId: string;
companyId: string;
// edit state
editDraft: RoutineEditDraft;
setEditDraft: React.Dispatch<React.SetStateAction<RoutineEditDraft>>;
routineDefaults: RoutineEditDraft;
dirtyFields: RoutineHistoryDirtyFieldDescriptor[];
isEditDirty: boolean;
sectionDirtyFields: (section: RoutineSectionKey) => RoutineHistoryDirtyFieldDescriptor[];
isSectionDirty: (section: RoutineSectionKey) => boolean;
discardSection: (section: RoutineSectionKey) => void;
// save
saveRoutine: UseMutationResult<unknown, unknown, void, unknown>;
saveConflict: boolean;
reloadLatest: () => void;
// header / automation
automationEnabled: boolean;
automationLabel: string;
automationLabelClassName: string;
automationToggleDisabled: boolean;
onToggleAutomation: () => void;
onOpenRunDialog: () => void;
runRoutinePending: boolean;
// triggers
newTrigger: NewTriggerDraft;
setNewTrigger: React.Dispatch<React.SetStateAction<NewTriggerDraft>>;
createTrigger: UseMutationResult<RoutineTriggerResponse, unknown, void, unknown>;
updateTrigger: UseMutationResult<unknown, unknown, { id: string; patch: Record<string, unknown> }, unknown>;
deleteTrigger: UseMutationResult<unknown, unknown, string, unknown>;
rotateTrigger: UseMutationResult<RotateRoutineTriggerResponse, unknown, string, unknown>;
// secrets
secretMessage: SecretMessage | null;
setSecretMessage: (message: SecretMessage | null) => void;
copySecretValue: (label: string, value: string) => void;
availableSecrets: SecretList;
createSecret: UseMutationResult<CompanySecret, unknown, { name: string; value: string }, unknown>;
// entities
agents: AgentList;
projects: ProjectList;
agentById: Map<string, AgentList[number]>;
projectById: Map<string, ProjectList[number]>;
assigneeOptions: InlineEntityOption[];
projectOptions: InlineEntityOption[];
recentAssigneeIds: string[];
recentProjectIds: string[];
mentionOptions: MentionOption[];
currentAssignee: AgentList[number] | null;
currentProject: ProjectList[number] | null;
// operate data
routineRuns: RoutineRunList | undefined;
activity: RoutineActivityList | undefined;
hasLiveRun: boolean;
activeIssueId: string | undefined;
// refs
titleInputRef: React.RefObject<HTMLTextAreaElement | null>;
descriptionEditorRef: React.RefObject<MarkdownEditorRef | null>;
assigneeSelectorRef: React.RefObject<HTMLButtonElement | null>;
projectSelectorRef: React.RefObject<HTMLButtonElement | null>;
// history restore plumbing
onHistoryRestoreSecretMaterials: (
response: import("../../api/routines").RestoreRoutineRevisionResponse,
) => void;
onHistoryRestored: (
response: import("../../api/routines").RestoreRoutineRevisionResponse,
) => void;
navigateToSection: (section: RoutineSectionKey, options?: { replace?: boolean }) => void;
};
export const RoutineDetailContext = createContext<RoutineDetailContextValue | null>(null);
export function useRoutineDetail(): RoutineDetailContextValue {
const value = useContext(RoutineDetailContext);
if (!value) {
throw new Error("useRoutineDetail must be used within a RoutineDetailContext provider");
}
return value;
}