Files
PaperClipAI/ui/src/pages/Routines.tsx
T
7b9334cd10 Draft: Build agent-ready design proposal system (#16875)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People need consistent UI patterns to review and control that work.
> - The current board uses related layouts and controls with different
spacing, type, and behavior.
> - That drift makes it hard for a designer or an agent to build a new
page from a reliable reference.
> - Storybook can show the shared patterns and the states they need to
support.
> - This pull request adds design proposals, a local sandbox, and
reusable UI controls so people can compare the patterns in context.
> - The benefit is a clearer design system that agents can use to build
consistent UI.

## Linked Issues or Issue Description

**Subsystem affected**

`ui/` — React board UI and Storybook.

**Problem or motivation**

Board pages repeat visual and interaction decisions. Storybook did not
provide a complete reference for task views, conversation states,
typography, toolbars, tabs, and light/dark colors. People could not
compare a proposed pattern with the running board in one place.

**Proposed solution**

Add a staged Design Proposals catalog with foundations, components,
layouts, and page specimens. Add an opt-in sandbox. Reuse production
components where possible. Include synthetic conversation states and
shared controls so reviewers can compare and refine them.

**Alternatives considered**

Separate static mockups would not show keyboard behavior, responsive
layout, or real component states. Rebuilding each page only in Storybook
would allow the reference to drift from the board.

**Roadmap alignment**

This improves existing board UI and Skills Manager surfaces. It does not
add a new control-plane capability.

## What Changed

- Added dark and light color references, typography and conversation
specimens, and component stories for buttons, tabs, toolbars, sidebar,
popovers, activity, and task lists.
- Added a local design sandbox with synthetic task conversation playback
and representative states.
- Shared collection toolbars and navigation patterns across task, agent,
project, routine, skill, connector, and artifact pages.
- Added the streamlined Skills tabs and a My Skills collection that uses
the shared skill cards.
- Restored saved-thread access during synthetic playback, kept connector
searchbox semantics, and updated reproduction checks after moving trial
stories.
- Documented the proposed patterns and added focused UI, catalog, and
browser checks.

## Verification

- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node
scripts/storybook-catalog.mjs --check` passed.
- Full workspace `pnpm -r typecheck` and `pnpm build` passed after
merging master. After the review fixes, focused UI typecheck, `pnpm
build-storybook`, 23 Skills Sources tests, and all 15 Storybook
typography browser tests passed.
- Focused task-thread and system-notice suites passed: 251 tests.
Connector Browse passed 84 tests; the connector, attachment, recovery,
and Storybook flyout browser journeys passed locally.
- A local full Vitest run hit one intermittent chat-channels integration
failure; that test passed in isolation. GitHub CI
[39347996059](https://github.com/paperclipai/paperclip/actions/runs/39347996059)
passed on the exact latest commit: 55 successful checks and two skipped
draft-only Storybook jobs.
- Greptile reviewed the exact latest commit at 5/5 with no new comments
or open review threads. GitHub reports the branch as cleanly mergeable.

## Risks

- This PR changes 206 files and exceeds the repository's 100-file review
target. Review by commit and consider a scoped split before merge.
- Design Proposals are staged references. Fixture interactions do not
prove API behavior, permissions, or provider execution.
- UI changes to shared controls may affect pages outside the proposal
sandbox. Focused browser checks and the full CI matrix passed.

## Model Used

- OpenAI Codex, GPT-6 family. The exact runtime model ID and context
window are not exposed in this session. The model used reasoning, code
execution, browser inspection, and local tests. Earlier branch commits
were also AI-assisted in this Codex conversation.

## 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 available in this session)
- [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 where applicable
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused 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 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: Scott Tong <scott@scotts-mbp-m5-max.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-19 17:45:26 -07:00

1389 lines
57 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 { ChevronDown, ChevronRight, 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 { RoutinesToolbar, type RoutineGroupBy, type RoutineSortField, type RoutineSortDir } from "@/components/RoutinesToolbar";
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 RoutinesTab = "routines" | "runs";
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 [query, setQuery] = useState("");
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(
() => {
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
const matches = folderFilteredRoutines.filter(routine => {
const text = `${routine.title} ${routine.description ?? ""}`.toLowerCase();
return terms.every(term => text.includes(term));
});
return sortRoutines(matches, routineViewState.sortField, routineViewState.sortDir);
},
[folderFilteredRoutines, query, 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">
<RoutinesToolbar
query={query} onQueryChange={setQuery}
sortField={routineViewState.sortField} sortDir={routineViewState.sortDir} groupBy={routineViewState.groupBy}
onSortChange={(sortField, sortDir) => updateRoutineView({ sortField, sortDir })}
onGroupChange={groupBy => updateRoutineView({ groupBy, collapsedGroups: [] })}
onNewFolder={routineViewState.groupBy === "folder" && !hasRoutineFolders ? () => openCreateFolder() : undefined}
onCreateRoutine={openCreateRoutine}
runsHref={streamlinedUiEnabled ? auditSectionHref("runs", {}) : undefined}
selectMode={selectMode} onToggleSelect={showFolderRail ? () => setSelectMode(current => !current) : undefined}
/>
{!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">
{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={query.trim() ? "No routines match your search." : 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>
);
}