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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user