diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx new file mode 100644 index 00000000..1d591dd7 --- /dev/null +++ b/ui/src/pages/Routines.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, RoutineListItem } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Routines, buildRoutineGroups } from "./Routines"; + +let currentSearch = ""; + +const navigateMock = vi.fn(); +const routinesListMock = vi.fn<(companyId: string) => Promise>(); +const issuesListMock = vi.fn<(companyId: string, filters?: Record) => Promise>(); +const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => ( +
{issues.map((issue) => issue.title).join(", ")}
+)); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }), + useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()], +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => ({ pushToast: vi.fn() }), +})); + +vi.mock("../api/routines", () => ({ + routinesApi: { + list: (companyId: string) => routinesListMock(companyId), + create: vi.fn(), + update: vi.fn(), + run: vi.fn(), + }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + list: (companyId: string, filters?: Record) => issuesListMock(companyId, filters), + update: vi.fn(), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: { + list: vi.fn(async () => [ + { + id: "agent-1", + companyId: "company-1", + name: "Agent One", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-one", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + { + id: "agent-2", + companyId: "company-1", + name: "Agent Two", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-two", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + ]), + }, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: { + list: vi.fn(async () => [ + { + id: "project-1", + companyId: "company-1", + urlKey: "project-alpha", + goalId: null, + goalIds: [], + goals: [], + name: "Project Alpha", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#22c55e", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + { + id: "project-2", + companyId: "company-1", + urlKey: "project-beta", + goalId: null, + goalIds: [], + goals: [], + name: "Project Beta", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#38bdf8", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]), + }, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: { + getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })), + }, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: { + liveRunsForCompany: vi.fn(async () => []), + }, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props), +})); + +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ label: string }> }) => ( +
{items.map((item) => item.label).join(", ")}
+ ), +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: unknown }) =>
{children as never}
, + TabsContent: ({ children }: { children: unknown }) =>
{children as never}
, +})); + +vi.mock("../components/MarkdownEditor", () => ({ + MarkdownEditor: () =>
, +})); + +vi.mock("../components/InlineEntitySelector", () => ({ + InlineEntitySelector: () => , +})); + +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, + routineRunNeedsConfiguration: () => false, +})); + +vi.mock("../components/RoutineVariablesEditor", () => ({ + RoutineVariablesEditor: () => null, + RoutineVariablesHint: () => null, +})); + +vi.mock("../components/AgentIconPicker", () => ({ + AgentIcon: () => , +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createRoutine(overrides: Partial): RoutineListItem { + return { + id: "routine-1", + companyId: "company-1", + projectId: "project-1", + goalId: null, + parentIssueId: null, + title: "Routine title", + description: null, + assigneeAgentId: "agent-1", + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1000", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Routine execution issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1000, + originKind: "routine_execution", + originId: "routine-1", + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + lastActivityAt: new Date("2026-04-01T00:00:00.000Z"), + isUnreadForMe: false, + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +describe("Routines page", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentSearch = ""; + navigateMock.mockReset(); + routinesListMock.mockReset(); + issuesListMock.mockReset(); + issuesListRenderMock.mockClear(); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("groups routines by project using project names for the section labels", () => { + const groups = buildRoutineGroups( + [ + createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }), + createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }), + ], + "project", + new Map([ + ["project-1", { name: "Project Alpha" }], + ["project-2", { name: "Project Beta" }], + ]), + new Map([ + ["agent-1", { name: "Agent One" }], + ["agent-2", { name: "Agent Two" }], + ]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); + }); + + it("shows recent runs through the issues list scoped to routine execution issues", async () => { + currentSearch = "tab=runs"; + routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]); + issuesListMock.mockResolvedValue([ + createIssue({ id: "issue-1", title: "Routine execution A" }), + createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index b58cea8c..85e32796 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -1,19 +1,25 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@/lib/router"; -import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; +import { useNavigate, useSearchParams } from "@/lib/router"; +import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { issuesApi } from "../api/issues"; +import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; +import { groupBy } from "../lib/groupBy"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; +import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; +import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; @@ -34,6 +40,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -41,6 +48,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; @@ -71,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) { return enabled ? "active" : "paused"; } +type RoutinesTab = "routines" | "runs"; +type RoutineGroupBy = "none" | "project" | "assignee"; + +type RoutineViewState = { + groupBy: RoutineGroupBy; + collapsedGroups: string[]; +}; + +type RoutineGroup = { + key: string; + label: string | null; + items: RoutineListItem[]; +}; + +const defaultRoutineViewState: RoutineViewState = { + groupBy: "none", + 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 formatRoutineRunStatus(value: string | null | undefined) { + if (!value) return null; + return value.replaceAll("_", " "); +} + +export function buildRoutineGroups( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + if (groupByValue === "none") { + return [{ key: "__all", label: null, items: routines }]; + } + + 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]!, + })); +} + +function buildRoutinesTabHref(tab: RoutinesTab) { + return tab === "runs" ? "/routines?tab=runs" : "/routines"; +} + +function RoutineListRow({ + routine, + projectById, + agentById, + runningRoutineId, + statusMutationRoutineId, + onNavigate, + onRunNow, + onToggleEnabled, + onToggleArchived, +}: { + routine: RoutineListItem; + projectById: Map; + agentById: Map; + runningRoutineId: string | null; + statusMutationRoutineId: string | null; + onNavigate: (routineId: string) => void; + onRunNow: (routine: RoutineListItem) => void; + onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; + onToggleArchived: (routine: RoutineListItem) => void; +}) { + const enabled = routine.status === "active"; + const isArchived = routine.status === "archived"; + const isStatusPending = statusMutationRoutineId === routine.id; + const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; + const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; + + return ( +
onNavigate(routine.id)} + > +
+
+ {routine.title} + {(isArchived || routine.status === "paused") ? ( + + {isArchived ? "archived" : "paused"} + + ) : null} +
+
+ + + {project?.name ?? "Unknown project"} + + + {agent?.icon ? : null} + {agent?.name ?? "Unknown agent"} + + + {formatLastRunTimestamp(routine.lastRun?.triggeredAt)} + {routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""} + +
+
+ +
event.stopPropagation()}> +
+ onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} + /> + + {isArchived ? "Archived" : enabled ? "On" : "Off"} + +
+ + + + + + + onNavigate(routine.id)}> + Edit + + onRunNow(routine)} + > + {runningRoutineId === routine.id ? "Running..." : "Run now"} + + + onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + > + {enabled ? "Pause" : "Enable"} + + onToggleArchived(routine)} + disabled={isStatusPending} + > + {routine.status === "archived" ? "Restore" : "Archive"} + + + +
+
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { pushToast } = useToast(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); @@ -86,6 +286,7 @@ export function Routines() { const [runDialogRoutine, setRunDialogRoutine] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines"; const [draft, setDraft] = useState<{ title: string; description: string; @@ -105,11 +306,19 @@ export function Routines() { catchUpPolicy: "skip_missed", variables: [], }); + const routineViewStateKey = selectedCompanyId + ? `paperclip:routines-view:${selectedCompanyId}` + : "paperclip:routines-view"; + const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); 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!), @@ -130,6 +339,17 @@ export function Routines() { queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], + queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), + enabled: !!selectedCompanyId && activeTab === "runs", + }); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId && activeTab === "runs", + refetchInterval: 5000, + }); useEffect(() => { autoResizeTextarea(titleInputRef.current); @@ -163,6 +383,13 @@ export function Routines() { navigate(`/routines/${routine.id}?tab=triggers`); }, }); + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] }); + }, + }); const updateRoutineStatus = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), @@ -250,10 +477,45 @@ export function Routines() { () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); + const liveIssueIds = useMemo(() => { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; + }, [liveRuns]); + const routineGroups = useMemo( + () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), + [agentById, projectById, routineViewState.groupBy, routines], + ); + const recentRunsIssueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Recent Runs", + buildRoutinesTabHref("runs"), + "issues", + ), + [], + ); const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; + function updateRoutineView(patch: Partial) { + setRoutineViewState((current) => { + const next = { ...current, ...patch }; + saveRoutineViewState(routineViewStateKey, next); + return next; + }); + } + + function handleTabChange(tab: string) { + const nextTab = tab === "runs" ? "runs" : "routines"; + startTransition(() => { + navigate(buildRoutinesTabHref(nextTab)); + }); + } + function handleRunNow(routine: RoutineListItem) { const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const needsConfiguration = routineRunNeedsConfiguration({ @@ -268,6 +530,20 @@ export function Routines() { runRoutine.mutate({ id: routine.id, data: {} }); } + function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { + 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 ; } @@ -294,6 +570,68 @@ export function Routines() {
+ + + +
+

+ {(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"} +

+ + + + + +
+ {([ + ["project", "Project"], + ["assignee", "Agent"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+
+ + updateIssue.mutate({ id, data })} + /> + +
+ { @@ -561,154 +899,64 @@ export function Routines() { ) : null} -
- {(routines ?? []).length === 0 ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {(routines ?? []).map((routine) => { - const enabled = routine.status === "active"; - const isArchived = routine.status === "archived"; - const isStatusPending = statusMutationRoutineId === routine.id; - return ( - navigate(`/routines/${routine.id}`)} - > - - - - - - - - ); - })} - -
NameProjectAgentLast runEnabled -
-
- - {routine.title} - - {(isArchived || routine.status === "paused") && ( -
- {isArchived ? "archived" : "paused"} -
- )} -
-
- {routine.projectId ? ( -
- - {projectById.get(routine.projectId)?.name ?? "Unknown"} -
- ) : ( - - )} -
- {routine.assigneeAgentId ? (() => { - const agent = agentById.get(routine.assigneeAgentId); - return agent ? ( -
- - {agent.name} -
- ) : ( - Unknown - ); - })() : ( - - )} -
-
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
- {routine.lastRun ? ( -
{routine.lastRun.status.replaceAll("_", " ")}
- ) : null} -
e.stopPropagation()}> -
- - updateRoutineStatus.mutate({ - id: routine.id, - status: nextRoutineStatus(routine.status, !enabled), - }) - } - disabled={isStatusPending || isArchived} - aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} - /> - - {isArchived ? "Archived" : enabled ? "On" : "Off"} - -
-
e.stopPropagation()}> - - - - - - navigate(`/routines/${routine.id}`)}> - Edit - - handleRunNow(routine)} - > - {runningRoutineId === routine.id ? "Running..." : "Run now"} - - - - updateRoutineStatus.mutate({ - id: routine.id, - status: enabled ? "paused" : "active", - }) - } - disabled={isStatusPending || isArchived} - > - {enabled ? "Pause" : "Enable"} - - - updateRoutineStatus.mutate({ - id: routine.id, - status: routine.status === "archived" ? "active" : "archived", - }) - } - disabled={isStatusPending} - > - {routine.status === "archived" ? "Restore" : "Archive"} - - - -
-
- )} -
+ {activeTab === "routines" ? ( +
+ {(routines ?? []).length === 0 ? ( +
+ +
+ ) : ( +
+ {routineGroups.map((group) => ( + { + updateRoutineView({ + collapsedGroups: open + ? routineViewState.collapsedGroups.filter((item) => item !== group.key) + : [...routineViewState.collapsedGroups, group.key], + }); + }} + > + {group.label ? ( +
+ + + + {group.label} + + + + {group.items.length} + +
+ ) : null} + + {group.items.map((routine) => ( + navigate(`/routines/${routineId}`)} + onRunNow={handleRunNow} + onToggleEnabled={handleToggleEnabled} + onToggleArchived={handleToggleArchived} + /> + ))} + +
+ ))} +
+ )} +
+ ) : null}