Files
PaperClipAI/ui/src/pages/Routines.tsx
T
1ef3b08714 feat(ui): integrate agent personas across the app (#13171)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A stable agent persona is useful only when the same identity appears
across the app.
> - Lists, task messages, selectors, and activity feeds need inexpensive
static avatars.
> - Onboarding and agent headers need a larger character with
expressions and pointer tracking.
> - This pull request connects the persona foundation to those existing
views and preserves onboarding draft assignments.
> - Full-page stories and Linux checks make the placements and
performance contract reviewable.

## Linked Issues or Issue Description

**Problem or motivation**

Agents need a stable visual identity in lists, tasks, onboarding, and
configuration. External tools also need an image URL for that identity.

**Proposed solution**

Assign each agent a permanent palette from a fixed ClipLab character
library. Store the assignment on the agent. Render and cache preset PNG
URLs on demand. Use static images in dense views and one animated
character in larger placements.

**Alternatives considered**

A generated image bundle requires a separate asset build. A live
renderer in every avatar adds unnecessary work in large lists. Arbitrary
uploaded images do not provide the requested shared character system.

**Roadmap alignment**

This improves agent identity across existing control-plane views. It
preserves agent permissions, company boundaries, and status labels.
ROADMAP.md has no separate ClipLab persona milestone.

Related approaches: #2422 adds configurable image URLs and DiceBear
generation; #5578 adds optional uploaded avatars. This work uses a
fixed, versioned character library and preset URLs.

## What Changed

- Replace agent icons with static persona images across lists, the
sidebar, org charts, tasks, comments, selectors, activity, and dashboard
views.
- Put one animated character in the agent header. Let it follow the
pointer across the page, with reduced-motion and touch fallbacks.
- Add larger padded characters to agent creation. Keep the palette
stable across draft refreshes and connection retries, then reveal it
after success.
- Pass appearance through shared projections rather than fetching each
agent separately.
- Add real full-page Storybook examples for the agent list, overview,
task, dashboard, new-agent dialog, and connection page.
- Add Linux screenshot, clipping, density, and 500-avatar performance
checks.

## Verification

- `pnpm -r typecheck`, `pnpm build`, and token gates pass on the rebased
tree. Persona lifecycle tests pass.
- The rebased feature passes 38 Linux screenshot/performance checks,
including both display densities, corner pointer positions, and the
no-WebGL/no-live-download contract for 500 avatars.
- The final Linux persona suite passes all 38 visual, lifecycle,
density, and full-page checks using the standard Storybook configuration
and real on-demand avatar endpoint.
- Final local focused verification: 45 avatar/native-recovery tests
pass; UI identity/routine tests, typecheck/build, token gates, and
Storybook build pass.
- Current-head CI passes: full workspace/server tests, all serialized
server groups, typecheck/release checks, build, canary validation, and
end-to-end shards. The build passed after retrying a native-runner
concurrency-test failure; its three targeted cases also pass locally.
- Manual inspection covered stable identities in the app, header
placement, full-page mouse tracking, onboarding size, and task/dashboard
placements.


### Screenshots

Linux captures use synthetic Storybook fixtures. Full-page captures use
reduced motion. The live character, mouse tracking, and disposal are
checked separately.

<details>
<summary>Agent overview with the character in its header</summary>

<img
src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-agent-overview.png"
width="900" alt="Agent overview with the character in its header" />

</details>
<details>
<summary>Task messages and assignee identity</summary>

<img
src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-task.png"
width="900" alt="Task messages and assignee identity" />

</details>
<details>
<summary>Larger onboarding character with room for expressions</summary>

<img
src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-meet-your-next-agent.png"
width="900" alt="Larger onboarding character with room for expressions"
/>

</details>
<details>
<summary>Dashboard agent activity</summary>

<img
src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-company-dashboard.png"
width="900" alt="Dashboard agent activity" />

</details>

## Risks

- This PR depends on #13170, the persona foundation. Merge the
foundation first, then retarget this PR to master.
- Many placements change from icons to character silhouettes. Human
avatars and authoritative agent status labels retain their existing
behavior.
- Only one character can render live per view. Reduced motion,
hidden/offscreen content, touch input, and renderer failures use the
defined fallbacks.
- The full-page stories use fixture data. They do not contact a real
company or complete real provider sign-in.

## Model Used

OpenAI Codex, GPT-6 family. The exact model identifier and context
window are not exposed in this session. Used code editing, shell
execution, browser inspection, and Linux visual testing.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Tonio <tonework@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:57:53 -05:00

1483 lines
61 KiB
TypeScript

import { AgentAvatar } from "@/components/AgentAvatar";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, Navigate, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { accessApi } from "../api/access";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
import {
RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { auditSectionHref } from "./audit/audit-navigation";
import { routineDetailHref } from "../components/RoutineContextualSidebar";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
import type { FolderListItem } from "@paperclipai/shared";
import { Tabs } from "@/components/ui/tabs";
import {
AllUnfiledBanner,
BulkBar,
DeleteFolderDialog,
FolderChip,
FolderFormDialog,
FolderRail,
FolderSwatch,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
selectedFolderFromList,
type FolderSelection,
} from "../components/folders/FolderControls";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
skip_if_active: "Drop new trigger occurrences while a run is still active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.",
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
type RoutinesTab = "routines" | "runs";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
type RoutineViewState = {
sortField: RoutineSortField;
sortDir: RoutineSortDir;
groupBy: RoutineGroupBy;
collapsedGroups: string[];
};
type RoutineGroup = {
key: string;
label: string | null;
items: RoutineListItem[];
};
const builtInRoutineGroupKey = "__built_in_routines";
const defaultRoutineViewState: RoutineViewState = {
sortField: "title",
sortDir: "asc",
groupBy: "folder",
collapsedGroups: [],
};
function getRoutineViewState(key: string): RoutineViewState {
try {
const raw = localStorage.getItem(key);
if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) };
} catch {
// Ignore malformed local state and fall back to defaults.
}
return { ...defaultRoutineViewState };
}
function saveRoutineViewState(key: string, state: RoutineViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function timestampValue(value: Date | string | null | undefined) {
if (!value) return Number.NEGATIVE_INFINITY;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
}
function compareNullableText(left: string | null | undefined, right: string | null | undefined) {
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
}
type RoutineFolderGroupMeta = { name: string; position?: number | null };
function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}) {
return {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
folderId: input.folderId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
export function buildRoutineGroups(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
folderById: Map<string, RoutineFolderGroupMeta>,
): RoutineGroup[] {
if (groupByValue === "none") {
return [{ key: "__all", label: null, items: routines }];
}
if (groupByValue === "folder") {
const groups = groupBy(routines, (routine) => routine.folderId ?? "__unfiled");
return Object.keys(groups)
.sort((left, right) => {
if (left === "__unfiled" || right === "__unfiled") {
if (left === right) return 0;
return left === "__unfiled" ? 1 : -1;
}
const leftFolder = folderById.get(left);
const rightFolder = folderById.get(right);
const leftPosition = Number.isFinite(leftFolder?.position) ? leftFolder!.position! : Number.POSITIVE_INFINITY;
const rightPosition = Number.isFinite(rightFolder?.position) ? rightFolder!.position! : Number.POSITIVE_INFINITY;
const positionCompare = leftPosition - rightPosition;
if (positionCompare !== 0) return positionCompare;
const labelCompare = (leftFolder?.name ?? "Unknown folder").localeCompare(
rightFolder?.name ?? "Unknown folder",
undefined,
{ sensitivity: "base" },
);
return labelCompare || left.localeCompare(right);
})
.map((key) => ({
key,
label: key === "__unfiled" ? "Unfiled" : (folderById.get(key)?.name ?? "Unknown folder"),
items: groups[key]!,
}));
}
if (groupByValue === "project") {
const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project");
const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"),
items: groups[key]!,
}));
}
const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent");
const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"),
items: groups[key]!,
}));
}
export function isBuiltInRoutine(routine: Pick<RoutineListItem, "originKind">) {
return routine.originKind === "built_in_agent_bundle";
}
export function buildRoutineSections(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
folderById: Map<string, { name: string }>,
): RoutineGroup[] {
const builtInRoutines = routines.filter(isBuiltInRoutine);
const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine));
const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById, folderById)
.filter((group) => group.items.length > 0)
.map((group) => (
builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all"
? { ...group, label: "Custom routines" }
: group
));
if (builtInRoutines.length === 0) return customGroups;
return [
...customGroups,
{
key: builtInRoutineGroupKey,
label: "Built-in routines",
items: builtInRoutines,
},
];
}
export function sortRoutines(
routines: RoutineListItem[],
sortField: RoutineSortField,
sortDir: RoutineSortDir,
): RoutineListItem[] {
const direction = sortDir === "asc" ? 1 : -1;
return [...routines].sort((left, right) => {
let result = 0;
if (sortField === "title") {
result = compareNullableText(left.title, right.title);
} else if (sortField === "created") {
result = timestampValue(left.createdAt) - timestampValue(right.createdAt);
} else if (sortField === "lastRun") {
result = timestampValue(left.lastRun?.triggeredAt ?? left.lastTriggeredAt) -
timestampValue(right.lastRun?.triggeredAt ?? right.lastTriggeredAt);
} else {
result = timestampValue(left.updatedAt) - timestampValue(right.updatedAt);
}
if (result !== 0) return result * direction;
return compareNullableText(left.title, right.title);
});
}
function RoutineSectionHeader({
label,
count,
isOpen: _isOpen,
}: {
label: string;
count: number;
isOpen: boolean;
}) {
return (
<div className="flex items-center gap-2 px-2 py-1.5">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span data-routine-section-label className="text-sm font-medium">
{label}
</span>
</CollapsibleTrigger>
<span className="text-xs text-muted-foreground">
{count}
</span>
</div>
);
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { pushToast } = useToastActions();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedRoutineIds, setSelectedRoutineIds] = useState<string[]>([]);
const [moveAfterCreateIds, setMoveAfterCreateIds] = useState<string[]>([]);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const legacyRunsRequested = searchParams.get("tab") === "runs";
const activeTab: RoutinesTab = !streamlinedUiEnabled && legacyRunsRequested ? "runs" : "routines";
const [draft, setDraft] = useState<{
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}>({
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
const routineViewStateKey = selectedCompanyId
? `paperclip:routines-view:${selectedCompanyId}`
: "paperclip:routines-view";
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]);
useEffect(() => {
setRoutineViewState(getRoutineViewState(routineViewStateKey));
}, [routineViewStateKey]);
const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
enabled: !!selectedCompanyId && !streamlinedUiEnabled && activeTab === "runs",
});
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId && !streamlinedUiEnabled && activeTab === "runs",
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedLiveRuns.enabled,
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]);
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
projects: (projects ?? []).filter((project) => !project.archivedAt),
members: companyMembers?.users,
});
}, [agents, companyMembers?.users, projects]);
const createRoutine = useMutation({
mutationFn: () =>
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
onSuccess: async (routine) => {
setDraft({
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
setComposerOpen(false);
setAdvancedOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({
title: "Routine created",
body: routine.assigneeAgentId
? "Add the first trigger to turn it into a live workflow."
: "Draft saved. Add a default agent before enabling automation.",
tone: "success",
});
navigate(routineDetailHref(routine.id, "triggers"));
},
});
const createFolder = useMutation({
mutationFn: (payload: { name: string; color: string | null }) =>
foldersApi.create(selectedCompanyId!, { kind: "routine", ...payload }),
onSuccess: async (folder) => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
if (moveAfterCreateIds.length > 0) {
const ids = moveAfterCreateIds;
setMoveAfterCreateIds([]);
try {
await Promise.all(ids.map((itemId) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId: folder.id })
));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
} catch (moveError) {
pushToast({
title: "Folder created, move failed",
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
tone: "error",
});
return;
}
} else {
setFolderSelection(folder.id);
}
pushToast({ title: "Folder created", body: folder.name, tone: "success" });
},
onError: (mutationError) => {
pushToast({
title: "Failed to save folder",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the folder.",
tone: "error",
});
},
});
const updateFolder = useMutation({
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
foldersApi.update(selectedCompanyId!, folderId, payload),
onSuccess: async () => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
},
onError: (mutationError) => {
pushToast({
title: "Folder save failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the folder.",
tone: "error",
});
},
});
const deleteFolder = useMutation({
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
onSuccess: async (_, folderId) => {
if (folderSelection === folderId) setFolderSelection("all");
setDeleteFolderTarget(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Folder deleted", body: "Items moved to Unfiled.", tone: "success" });
},
onError: (mutationError) => {
pushToast({
title: "Folder delete failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not delete the folder.",
tone: "error",
});
},
});
const moveRoutineToFolder = useMutation({
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
},
onError: (mutationError) => {
pushToast({
title: "Move failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not move the routine.",
tone: "error",
});
},
});
const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
onMutate: ({ id }) => {
setStatusMutationRoutineId(id);
},
onSuccess: async (_, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(variables.id) }),
]);
},
onSettled: () => {
setStatusMutationRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Failed to update routine",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the routine.",
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
: {}),
...(data?.executionWorkspaceSettings !== undefined
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
: {}),
}),
onMutate: ({ id }) => {
setRunningRoutineId(id);
},
onSuccess: async (_, { id }) => {
setRunDialogRoutine(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
]);
},
onSettled: () => {
setRunningRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Routine run failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [composerOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
(projects ?? []).filter((project) => !project.archivedAt).map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[projects],
);
const agentById = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent])),
[agents],
);
const projectById = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const folderById = useMemo(
() => new Map((routineFolders?.folders ?? []).map((folder) => [folder.id, folder])),
[routineFolders],
);
const visibleRoutines = useMemo(
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
);
const liveIssueIds = useMemo(
() => collectLiveIssueIds(liveRuns, routineExecutionIssues),
[liveRuns, routineExecutionIssues],
);
const recentRunsIssueLinkState = useMemo(
() => createIssueDetailLocationState("Recent Runs", "/routines?tab=runs", "issues"),
[],
);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
});
},
});
function handleLegacyTabChange(tab: string) {
const nextTab: RoutinesTab = tab === "runs" ? "runs" : "routines";
startTransition(() => {
navigate(nextTab === "runs" ? "/routines?tab=runs" : "/routines");
});
}
const folderFilteredRoutines = useMemo(() => {
if (routineViewState.groupBy !== "folder") return visibleRoutines;
if (folderSelection === "all") return visibleRoutines;
if (folderSelection === "unfiled") return visibleRoutines.filter((routine) => !routine.folderId);
return visibleRoutines.filter((routine) => routine.folderId === folderSelection);
}, [folderSelection, routineViewState.groupBy, visibleRoutines]);
// Rail counts reflect the page's visible scope (archived hidden), not raw DB
// counts (ux-spec §5.3).
const railFolderResult = useMemo(() => {
if (!routineFolders) return routineFolders;
const counts = new Map<string, number>();
let unfiled = 0;
for (const routine of visibleRoutines) {
if (routine.folderId) counts.set(routine.folderId, (counts.get(routine.folderId) ?? 0) + 1);
else unfiled += 1;
}
return {
...routineFolders,
allCount: visibleRoutines.length,
unfiledCount: unfiled,
folders: routineFolders.folders.map((folder) => ({
...folder,
itemCount: counts.get(folder.id) ?? 0,
})),
};
}, [routineFolders, visibleRoutines]);
const sortedRoutines = useMemo(
() => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir),
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
);
const routineSections = useMemo(
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById, folderById),
[agentById, folderById, projectById, routineViewState.groupBy, sortedRoutines],
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
const showFolderRail = routineViewState.groupBy === "folder" && hasRoutineFolders;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
const next = { ...current, ...patch };
saveRoutineViewState(routineViewStateKey, next);
return next;
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
const value = folderSearchValue(selection);
if (value) params.set("folder", value);
else params.delete("folder");
return params;
});
}
function openCreateFolder(moveItemIds: string[] = []) {
setMoveAfterCreateIds(moveItemIds);
setFolderDialogTarget(null);
setFolderDialogOpen(true);
}
function openCreateRoutine() {
setDraft((current) => ({
...current,
folderId: folderSelection === "all" || folderSelection === "unfiled" ? null : folderSelection,
}));
setComposerOpen(true);
}
async function moveSelectedRoutines(folderId: string | null) {
const ids = selectedRoutineIds;
if (ids.length === 0) return;
try {
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId })));
setSelectedRoutineIds([]);
setSelectMode(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Routines moved", body: `${ids.length} routine${ids.length === 1 ? "" : "s"} filed.`, tone: "success" });
} catch (moveError) {
pushToast({
title: "Failed to move routines",
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
tone: "error",
});
}
}
function handleRunNow(routine: RoutineListItem) {
setRunDialogRoutine(routine);
}
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
if (!enabled && !routine.assigneeAgentId) {
pushToast({
title: "Default agent required",
body: "Set a default agent before enabling routine automation.",
tone: "warn",
});
return;
}
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
});
}
function handleToggleArchived(routine: RoutineListItem) {
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
});
}
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select an organization to view routines." />;
}
if (streamlinedUiEnabled && legacyRunsRequested) {
return <Navigate to={auditSectionHref("runs", {})} replace />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (!streamlinedUiEnabled && activeTab === "runs") {
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Routines</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
<Tabs value={activeTab} onValueChange={handleLegacyTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleLegacyTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
</Tabs>
<IssuesList
issues={routineExecutionIssues ?? []}
isLoading={recentRunsLoading}
error={recentRunsError as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:routine-recent-runs-view"
issueLinkState={recentRunsIssueLinkState}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h1 className="text-xl font-bold">Routines</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<div className="flex items-center gap-2">
{streamlinedUiEnabled ? (
<Button variant="outline" asChild>
<Link to={auditSectionHref("runs", {})}>View all runs</Link>
</Button>
) : null}
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
</div>
{!streamlinedUiEnabled ? (
<Tabs value={activeTab} onValueChange={handleLegacyTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleLegacyTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
</Tabs>
) : null}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
</p>
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["updated", "Updated"],
["created", "Created"],
["lastRun", "Last run"],
["title", "Title"],
] as const).map(([field, label]) => (
<button
key={field}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.sortField === field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
updateRoutineView(
routineViewState.sortField === field
? { sortDir: routineViewState.sortDir === "asc" ? "desc" : "asc" }
: { sortField: field, sortDir: field === "title" ? "asc" : "desc" },
);
}}
>
<span>{label}</span>
{routineViewState.sortField === field ? (
<span className="text-xs text-muted-foreground">
{routineViewState.sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Group">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["folder", "Folder"],
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
{routineViewState.groupBy === "folder" && !hasRoutineFolders ? (
<Button variant="outline" size="sm" onClick={() => openCreateFolder()}>
<Plus className="mr-2 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{showFolderRail ? (
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setSelectMode((current) => !current)}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
</div>
{routineViewState.groupBy === "folder" ? (
<div className="md:hidden">
<FolderChip
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</div>
<Dialog
open={composerOpen}
onOpenChange={(open) => {
if (!createRoutine.isPending) {
setComposerOpen(open);
}
}}
>
<DialogContent
showCloseButton={false}
className="flex max-h-(--sz-calc-18) max-w-3xl flex-col gap-0 overflow-hidden p-0"
>
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<div>
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground">
Define the recurring work first. Default project and agent are optional for draft routines.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setComposerOpen(false);
setAdvancedOpen(false);
}}
disabled={createRoutine.isPending}
>
Cancel
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
/>
</div>
<div className="px-5 pb-3">
<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={draft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentAvatar agent={currentAssignee} size={16} 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">Responsible</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentAvatar agent={assignee} size={16} 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={draft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => {
if (projectId) trackRecentProject(projectId);
setDraft((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 ?? "var(--project-none)" }}
/>
<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 ?? "var(--project-none)" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>filed in</span>
<Select
value={draft.folderId ?? "__unfiled"}
onValueChange={(value) => setDraft((current) => ({
...current,
folderId: value === "__unfiled" ? null : value,
}))}
>
<SelectTrigger className="h-8 w-auto min-w-32 border-0 bg-muted/50 px-2 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unfiled">Unfiled</SelectItem>
{(routineFolders?.folders ?? []).map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-(--sz-160px) text-sm text-muted-foreground"
mentions={mentionOptions}
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup. Draft routines stay paused until you add a default agent.
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim()
}
>
<Plus className="mr-2 h-4 w-4" />
{createRoutine.isPending ? "Creating..." : "Create routine"}
</Button>
{createRoutine.isError ? (
<p className="text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
{error ? (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"}
</CardContent>
</Card>
) : null}
<div className={cn(showFolderRail && "flex gap-4")}>
{showFolderRail ? (
<FolderRail
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="routines"
loading={foldersLoading}
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })}
onEdit={(folder) => {
setFolderDialogTarget(folder);
setFolderDialogOpen(true);
}}
onDelete={setDeleteFolderTarget}
/>
) : null}
<div className="min-w-0 flex-1">
{routineViewState.groupBy === "folder" && hasRoutineFolders ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
{folderSelection === "all" ? <FolderIconHeader label="All routines" count={sortedRoutines.length} /> : (
<div className="flex min-w-0 items-center gap-2 text-sm">
<FolderSwatch color={activeFolder?.color} />
<span className="truncate font-medium">{folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"}</span>
<span className="text-muted-foreground">{sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"}</span>
</div>
)}
</div>
) : null}
{routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? (
<AllUnfiledBanner
storageKey={`paperclip:routines-folder-nudge:${selectedCompanyId ?? "none"}`}
itemLabelPlural="routines"
onCreateFolder={() => openCreateFolder()}
/>
) : null}
{selectMode ? (
<BulkBar
selectedCount={selectedRoutineIds.length}
folders={routineFolders?.folders ?? []}
onMove={(folderId) => void moveSelectedRoutines(folderId)}
onCreateAndMove={() => openCreateFolder(selectedRoutineIds)}
onClear={() => setSelectedRoutineIds([])}
onDone={() => {
setSelectMode(false);
setSelectedRoutineIds([]);
}}
/>
) : null}
{visibleRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message="No active routines. Use Create routine to define the first recurring workflow."
/>
</div>
) : sortedRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message={folderSelection === "all" ? "No routines match this view." : "This folder is empty."}
/>
{folderSelection !== "all" ? (
<div className="mt-3 flex justify-center">
<Button size="sm" onClick={openCreateRoutine}>
<Plus className="mr-2 h-3.5 w-3.5" />
New routine in this folder
</Button>
</div>
) : null}
</div>
) : (
<div className="flex flex-col gap-3">
{routineSections.map((group) => {
const isOpen = !routineViewState.collapsedGroups.includes(group.key);
return (
<Collapsible
key={group.key}
open={isOpen}
onOpenChange={(open) => {
updateRoutineView({
collapsedGroups: open
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
: [...routineViewState.collapsedGroups, group.key],
});
}}
>
{group.label ? (
<RoutineSectionHeader
label={group.label}
count={group.items.length}
isOpen={isOpen}
/>
) : null}
<CollapsibleContent>
{group.items.map((routine) => (
<RoutineListRow
key={routine.id}
routine={routine}
projectById={projectById}
agentById={agentById}
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
href={`/routines/${routine.id}`}
runNowButton
divider={false}
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}
selectMode={selectMode}
selected={selectedRoutineIds.includes(routine.id)}
onSelectChange={(selectedRoutine, selected) => {
setSelectedRoutineIds((current) =>
selected
? Array.from(new Set([...current, selectedRoutine.id]))
: current.filter((id) => id !== selectedRoutine.id)
);
}}
extraMenuItems={
<MoveToMenu
folders={routineFolders?.folders ?? []}
currentFolderId={routine.folderId ?? null}
onMove={(folderId) => {
const previousFolderId = routine.folderId ?? null;
moveRoutineToFolder.mutate({ itemId: routine.id, folderId });
pushToast({
title: "Routine moved",
body: folderId
? `Moved "${routine.title}" to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
: `Moved "${routine.title}" to Unfiled.`,
tone: "success",
action: {
label: "Undo",
onClick: () => moveRoutineToFolder.mutate({ itemId: routine.id, folderId: previousFolderId }),
},
});
}}
onCreateAndMove={() => openCreateFolder([routine.id])}
/>
}
/>
))}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</div>
</div>
<FolderFormDialog
open={folderDialogOpen}
kind="routine"
folder={folderDialogTarget}
pending={createFolder.isPending || updateFolder.isPending}
onOpenChange={setFolderDialogOpen}
onSubmit={(payload) => {
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
else createFolder.mutate(payload);
}}
/>
<DeleteFolderDialog
open={deleteFolderTarget !== null}
folder={deleteFolderTarget}
itemLabelPlural="routines"
pending={deleteFolder.isPending}
onOpenChange={(open) => {
if (!open) setDeleteFolderTarget(null);
}}
onConfirm={() => {
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
}}
/>
<MobileFolderSheet
open={mobileFoldersOpen}
onOpenChange={setMobileFoldersOpen}
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="Routines"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
if (!next) setRunDialogRoutine(null);
}}
companyId={selectedCompanyId}
routineName={runDialogRoutine?.title ?? null}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={runDialogRoutine?.projectId ?? null}
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
variables={runDialogRoutine?.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => {
if (!runDialogRoutine) return;
runRoutine.mutate({ id: runDialogRoutine.id, data });
}}
/>
</div>
);
}
function FolderIconHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex min-w-0 items-center gap-2 text-sm">
<Repeat className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-medium">{label}</span>
<span className="text-muted-foreground">{count} routine{count === 1 ? "" : "s"}</span>
</div>
);
}