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;
}
@@ -0,0 +1,564 @@
import { useMemo } from "react";
import {
ArrowRight,
Braces,
Clock3,
Edit3,
KeyRound,
Play,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioCardGroup } from "@/components/ui/radio-card";
import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
import { RoutineTriggerCard } from "../RoutineTriggerCard";
import { EnvVarEditor } from "../EnvVarEditor";
import { useRoutineDetail } from "./context";
import type { EnvBinding } from "@paperclipai/shared";
const concurrencyPolicyOptions = [
{
value: "coalesce_if_active",
title: "Coalesce if active",
description: "Keep one follow-up run queued while an active run is still working.",
},
{
value: "always_enqueue",
title: "Always enqueue",
description: "Queue every trigger occurrence, even if several runs stack up.",
},
{
value: "skip_if_active",
title: "Skip if active",
description: "Drop overlapping trigger occurrences while the routine is already active.",
},
];
const catchUpPolicyOptions = [
{
value: "skip_missed",
title: "Skip missed",
description: "Ignore schedule windows that were missed while paused.",
},
{
value: "enqueue_missed_with_cap",
title: "Enqueue missed with cap",
description: "Catch up missed schedule windows in capped batches after recovery.",
},
];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
none: "No authentication — the webhook URL itself acts as a shared secret.",
};
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
export function OverviewSection() {
const ctx = useRoutineDetail();
const {
routine,
editDraft,
setEditDraft,
assigneeOptions,
projectOptions,
recentAssigneeIds,
recentProjectIds,
agentById,
projectById,
currentAssignee,
currentProject,
mentionOptions,
assigneeSelectorRef,
projectSelectorRef,
descriptionEditorRef,
routineRuns,
activity,
saveRoutine,
navigateToSection,
} = ctx;
const activeTriggers = routine.triggers.length;
const nextFire = useMemo(() => {
const upcoming = routine.triggers
.filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt)
.map((trigger) => new Date(trigger.nextRunAt as Date))
.sort((a, b) => a.getTime() - b.getTime())[0];
return upcoming ? upcoming.toLocaleString() : null;
}, [routine.triggers]);
const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0;
const lastRun = (routineRuns ?? [])[0] ?? null;
const recentActivity = (activity ?? []).slice(0, 5);
return (
<div className="space-y-6">
{/* Assignment row */}
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={editDraft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) =>
setEditDraft((current) => ({ ...current, assigneeAgentId }))
}
onConfirm={() => {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? (
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={editDraft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
{!routine.assigneeAgentId ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
Default agent required. This routine can stay as a draft and still run manually, but
automation stays paused until you assign a default agent.
</div>
) : null}
{/* Instructions */}
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
{/* Variables peek */}
<div className="space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-3">
<SummaryCard
icon={Clock3}
label="Triggers"
value={activeTriggers === 0 ? "None" : `${activeTriggers} active`}
hint={nextFire ? `Next fire ${nextFire}` : "No schedule"}
to={() => navigateToSection("triggers")}
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
/>
<SummaryCard
icon={KeyRound}
label="Secrets"
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
hint="Manage bound secrets"
to={() => navigateToSection("secrets")}
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
/>
<SummaryCard
icon={Play}
label="Last run"
value={lastRun ? lastRun.status.replaceAll("_", " ") : "No runs"}
hint={lastRun ? timeAgo(lastRun.triggeredAt) : "Trigger a run"}
to={() => navigateToSection("runs")}
ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."}
/>
</div>
{/* Recent activity */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent activity
</p>
{recentActivity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="divide-y divide-border/60">
{recentActivity.map((event) => (
<div key={event.id} className="flex items-center gap-2 py-1.5 text-xs">
<Badge variant="outline" className="shrink-0 font-mono">
{event.action}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{event.details && Object.keys(event.details).length > 0
? Object.keys(event.details).slice(0, 3).join(" · ")
: ""}
</span>
<span className="shrink-0 text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
</div>
))}
<button
type="button"
onClick={() => navigateToSection("activity")}
className="flex items-center gap-1 pt-2 text-xs text-muted-foreground hover:text-foreground"
>
View all activity <ArrowRight className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
);
}
function SummaryCard({
icon: Icon,
label,
value,
hint,
to,
ariaLabel,
}: {
icon: typeof Clock3;
label: string;
value: string;
hint: string;
to: () => void;
ariaLabel: string;
}) {
return (
<button type="button" onClick={to} aria-label={ariaLabel} className="text-left">
<Card className="gap-2 p-4 transition-colors hover:border-border hover:bg-accent/30">
<CardContent className="space-y-1 p-0">
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
<ArrowRight className="ml-auto h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<p className="text-lg font-semibold">{value}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</CardContent>
</Card>
</button>
);
}
export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
return (
<div className="space-y-4">
{/* Add trigger form */}
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">Add trigger</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Kind</Label>
<Select
value={newTrigger.kind}
onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
{kind}
{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newTrigger.kind === "schedule" && (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={newTrigger.cronExpression}
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
/>
</div>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select
value={newTrigger.signingMode}
onValueChange={(signingMode) =>
setNewTrigger((current) => ({ ...current, signingMode }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={newTrigger.replayWindowSec}
onChange={(event) =>
setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))
}
/>
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<EmptyState icon={Clock3} message="No triggers yet." />
) : (
<div className="space-y-3">
{routine.triggers.map((trigger) => (
<RoutineTriggerCard
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
onDelete={(id) => deleteTrigger.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
export function VariablesSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, navigateToSection } = ctx;
const hasVariables = editDraft.variables.length > 0;
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs">
<span className="flex-1 text-muted-foreground">
Variables are auto-detected from <code className="font-mono">{"{{placeholders}}"}</code> in
the title &amp; instructions. The variable name is read-only rename by editing the
placeholder.
</span>
<Button variant="secondary" size="sm" onClick={() => navigateToSection("overview")}>
<Edit3 className="mr-1.5 h-3.5 w-3.5" />
Edit instructions
</Button>
</div>
{hasVariables ? (
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
) : (
<EmptyState
icon={Braces}
message="No variables yet. Add a {{placeholder}} in the title or instructions to create one."
action="Edit instructions"
onAction={() => navigateToSection("overview")}
/>
)}
</div>
);
}
export function SecretsSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx;
return (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
Routine secrets apply to every task this routine creates. They override matching keys in
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
</div>
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<EnvVarEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
secrets={availableSecrets}
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))}
/>
</div>
);
}
export function DeliverySection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft } = ctx;
return (
<div className="space-y-6">
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Concurrency
</p>
<RadioCardGroup
ariaLabel="Concurrency policy"
value={editDraft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) =>
setEditDraft((current) => ({ ...current, concurrencyPolicy }))
}
options={concurrencyPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Catch-up
</p>
<RadioCardGroup
ariaLabel="Catch-up policy"
value={editDraft.catchUpPolicy}
onValueChange={(catchUpPolicy) =>
setEditDraft((current) => ({ ...current, catchUpPolicy }))
}
options={catchUpPolicyOptions}
/>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { useMemo } from "react";
import { Activity as ActivityIcon, Play } from "lucide-react";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { LiveRunWidget } from "../LiveRunWidget";
import { RoutineHistoryTab } from "../RoutineHistoryTab";
import { RoutineActivityRow } from "../RoutineActivityRow";
import { useRoutineDetail } from "./context";
export function RunsSection() {
const ctx = useRoutineDetail();
const { routine, routineRuns, hasLiveRun, activeIssueId, onOpenRunDialog } = ctx;
const runs = routineRuns ?? [];
return (
<div className="space-y-4">
{hasLiveRun && activeIssueId ? (
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
) : null}
{runs.length === 0 ? (
<EmptyState
icon={Play}
message="No runs yet. Trigger a run from the header or wait for the schedule."
action="Run now"
onAction={onOpenRunDialog}
/>
) : (
<div className="divide-y divide-border rounded-lg border border-border">
{runs.map((run) => (
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0">
{run.source}
</Badge>
<Badge
variant={run.status === "failed" ? "destructive" : "secondary"}
className="shrink-0"
>
{run.status.replaceAll("_", " ")}
</Badge>
{run.trigger ? (
<span className="truncate text-muted-foreground">
{run.trigger.label ?? run.trigger.kind}
</span>
) : null}
{run.linkedIssue ? (
<Link
to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`}
className="truncate text-muted-foreground hover:underline"
>
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
</Link>
) : null}
</div>
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
{timeAgo(run.triggeredAt)}
</span>
</div>
))}
</div>
)}
</div>
);
}
export function ActivitySection() {
const ctx = useRoutineDetail();
const { activity } = ctx;
const events = activity ?? [];
const groups = useMemo(() => {
const byDay = new Map<string, typeof events>();
for (const event of events) {
let label = "Earlier";
try {
label = new Date(event.createdAt).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
});
} catch {
/* keep fallback label */
}
const bucket = byDay.get(label) ?? [];
bucket.push(event);
byDay.set(label, bucket);
}
return Array.from(byDay.entries());
}, [events]);
if (events.length === 0) {
return <EmptyState icon={ActivityIcon} message="No activity yet." />;
}
return (
<div className="space-y-4">
{groups.map(([day, dayEvents]) => (
<div key={day}>
<div className="sticky top-0 bg-background py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{day}
</div>
<div>
{dayEvents.map((event) => (
<RoutineActivityRow key={event.id} event={event} />
))}
</div>
</div>
))}
</div>
);
}
export function HistorySection() {
const ctx = useRoutineDetail();
const {
routine,
isEditDirty,
dirtyFields,
routineDefaults,
setEditDraft,
saveRoutine,
agentById,
projectById,
availableSecrets,
onHistoryRestoreSecretMaterials,
onHistoryRestored,
} = ctx;
return (
<RoutineHistoryTab
routine={routine}
isEditDirty={isEditDirty}
dirtyFields={dirtyFields}
onDiscardEdits={() => setEditDraft(routineDefaults)}
onSaveEdits={() => {
if (!saveRoutine.isPending && routine.title.trim()) {
saveRoutine.mutate();
}
}}
agents={agentById}
projects={projectById}
secrets={availableSecrets}
onRestoreSecretMaterials={onHistoryRestoreSecretMaterials}
onRestored={onHistoryRestored}
/>
);
}