mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
Save task sort choices in URLs across task views. Open routine tasks by creation time, newest first, with stable sorting before pagination. Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
1386 lines
57 KiB
TypeScript
1386 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";
|
|
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
|
|
|
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 = useUpdateIssueMutation({
|
|
companyId: selectedCompanyId,
|
|
invalidateKeys: selectedCompanyId ? [[...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}
|
|
tasksHref="/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc"
|
|
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>
|
|
);
|
|
}
|