mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
feat(ui): improve routines list and recent runs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f515f2aa12
commit
c89349687f
2 changed files with 766 additions and 151 deletions
367
ui/src/pages/Routines.test.tsx
Normal file
367
ui/src/pages/Routines.test.tsx
Normal file
|
|
@ -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<RoutineListItem[]>>();
|
||||||
|
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
||||||
|
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
||||||
|
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
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<string, unknown>) => 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 }> }) => (
|
||||||
|
<div>{items.map((item) => item.label).join(", ")}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tabs", () => ({
|
||||||
|
Tabs: ({ children }: { children: unknown }) => <div>{children as never}</div>,
|
||||||
|
TabsContent: ({ children }: { children: unknown }) => <div>{children as never}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/MarkdownEditor", () => ({
|
||||||
|
MarkdownEditor: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/InlineEntitySelector", () => ({
|
||||||
|
InlineEntitySelector: () => <button type="button">selector</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/RoutineRunVariablesDialog", () => ({
|
||||||
|
RoutineRunVariablesDialog: () => null,
|
||||||
|
routineRunNeedsConfiguration: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/RoutineVariablesEditor", () => ({
|
||||||
|
RoutineVariablesEditor: () => null,
|
||||||
|
RoutineVariablesHint: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/AgentIconPicker", () => ({
|
||||||
|
AgentIcon: () => <span data-testid="agent-icon" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createRoutine(overrides: Partial<RoutineListItem>): 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> = {}): 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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Routines />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||||
import { routinesApi } from "../api/routines";
|
import { routinesApi } from "../api/routines";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { groupBy } from "../lib/groupBy";
|
||||||
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
|
@ -34,6 +40,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -41,6 +48,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||||
|
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
|
|
@ -71,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||||
return enabled ? "active" : "paused";
|
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<string, { name: string }>,
|
||||||
|
agentById: Map<string, { name: string }>,
|
||||||
|
): 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<string, { name: string; color?: string | null }>;
|
||||||
|
agentById: Map<string, { name: string; icon?: string | null }>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
|
||||||
|
onClick={() => onNavigate(routine.id)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||||
|
{(isArchived || routine.status === "paused") ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "archived" : "paused"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span>{project?.name ?? "Unknown project"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||||
|
<span>{agent?.name ?? "Unknown agent"}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||||
|
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
size="lg"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={runningRoutineId === routine.id || isArchived}
|
||||||
|
onClick={() => onRunNow(routine)}
|
||||||
|
>
|
||||||
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
>
|
||||||
|
{enabled ? "Pause" : "Enable"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleArchived(routine)}
|
||||||
|
disabled={isStatusPending}
|
||||||
|
>
|
||||||
|
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Routines() {
|
export function Routines() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
@ -86,6 +286,7 @@ export function Routines() {
|
||||||
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||||
const [composerOpen, setComposerOpen] = useState(false);
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
|
||||||
const [draft, setDraft] = useState<{
|
const [draft, setDraft] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -105,11 +306,19 @@ export function Routines() {
|
||||||
catchUpPolicy: "skip_missed",
|
catchUpPolicy: "skip_missed",
|
||||||
variables: [],
|
variables: [],
|
||||||
});
|
});
|
||||||
|
const routineViewStateKey = selectedCompanyId
|
||||||
|
? `paperclip:routines-view:${selectedCompanyId}`
|
||||||
|
: "paperclip:routines-view";
|
||||||
|
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Routines" }]);
|
setBreadcrumbs([{ label: "Routines" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoutineViewState(getRoutineViewState(routineViewStateKey));
|
||||||
|
}, [routineViewStateKey]);
|
||||||
|
|
||||||
const { data: routines, isLoading, error } = useQuery({
|
const { data: routines, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
||||||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||||
|
|
@ -130,6 +339,17 @@ export function Routines() {
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
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(() => {
|
useEffect(() => {
|
||||||
autoResizeTextarea(titleInputRef.current);
|
autoResizeTextarea(titleInputRef.current);
|
||||||
|
|
@ -163,6 +383,13 @@ export function Routines() {
|
||||||
navigate(`/routines/${routine.id}?tab=triggers`);
|
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updateRoutineStatus = useMutation({
|
const updateRoutineStatus = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
|
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])),
|
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
||||||
[projects],
|
[projects],
|
||||||
);
|
);
|
||||||
|
const liveIssueIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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 runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
||||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
function updateRoutineView(patch: Partial<RoutineViewState>) {
|
||||||
|
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) {
|
function handleRunNow(routine: RoutineListItem) {
|
||||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
const needsConfiguration = routineRunNeedsConfiguration({
|
const needsConfiguration = routineRunNeedsConfiguration({
|
||||||
|
|
@ -268,6 +530,20 @@ export function Routines() {
|
||||||
runRoutine.mutate({ id: routine.id, data: {} });
|
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) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +570,68 @@ export function Routines() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
|
<PageTabBar
|
||||||
|
align="start"
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
items={[
|
||||||
|
{ value: "routines", label: "Routines" },
|
||||||
|
{ value: "runs", label: "Recent Runs" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TabsContent value="routines" className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs">
|
||||||
|
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Group</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-44 p-0">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{([
|
||||||
|
["project", "Project"],
|
||||||
|
["assignee", "Agent"],
|
||||||
|
["none", "None"],
|
||||||
|
] as const).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
|
||||||
|
routineViewState.groupBy === value
|
||||||
|
? "bg-accent/50 text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="runs">
|
||||||
|
<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 })}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={composerOpen}
|
open={composerOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -561,154 +899,64 @@ export function Routines() {
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
{activeTab === "routines" ? (
|
||||||
{(routines ?? []).length === 0 ? (
|
<div>
|
||||||
<div className="py-12">
|
{(routines ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<div className="py-12">
|
||||||
icon={Repeat}
|
<EmptyState
|
||||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
icon={Repeat}
|
||||||
/>
|
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="overflow-x-auto">
|
) : (
|
||||||
<table className="min-w-full text-sm">
|
<div className="rounded-lg border border-border">
|
||||||
<thead>
|
{routineGroups.map((group) => (
|
||||||
<tr className="text-left text-xs text-muted-foreground border-b border-border">
|
<Collapsible
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
key={group.key}
|
||||||
<th className="px-3 py-2 font-medium">Project</th>
|
open={!routineViewState.collapsedGroups.includes(group.key)}
|
||||||
<th className="px-3 py-2 font-medium">Agent</th>
|
onOpenChange={(open) => {
|
||||||
<th className="px-3 py-2 font-medium">Last run</th>
|
updateRoutineView({
|
||||||
<th className="px-3 py-2 font-medium">Enabled</th>
|
collapsedGroups: open
|
||||||
<th className="w-12 px-3 py-2" />
|
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
|
||||||
</tr>
|
: [...routineViewState.collapsedGroups, group.key],
|
||||||
</thead>
|
});
|
||||||
<tbody>
|
}}
|
||||||
{(routines ?? []).map((routine) => {
|
>
|
||||||
const enabled = routine.status === "active";
|
{group.label ? (
|
||||||
const isArchived = routine.status === "archived";
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||||
return (
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||||
<tr
|
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||||
key={routine.id}
|
{group.label}
|
||||||
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
|
</span>
|
||||||
onClick={() => navigate(`/routines/${routine.id}`)}
|
</CollapsibleTrigger>
|
||||||
>
|
<span className="text-xs text-muted-foreground">
|
||||||
<td className="px-3 py-2.5">
|
{group.items.length}
|
||||||
<div className="min-w-[180px]">
|
</span>
|
||||||
<span className="font-medium">
|
</div>
|
||||||
{routine.title}
|
) : null}
|
||||||
</span>
|
<CollapsibleContent>
|
||||||
{(isArchived || routine.status === "paused") && (
|
{group.items.map((routine) => (
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<RoutineListRow
|
||||||
{isArchived ? "archived" : "paused"}
|
key={routine.id}
|
||||||
</div>
|
routine={routine}
|
||||||
)}
|
projectById={projectById}
|
||||||
</div>
|
agentById={agentById}
|
||||||
</td>
|
runningRoutineId={runningRoutineId}
|
||||||
<td className="px-3 py-2.5">
|
statusMutationRoutineId={statusMutationRoutineId}
|
||||||
{routine.projectId ? (
|
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
onRunNow={handleRunNow}
|
||||||
<span
|
onToggleEnabled={handleToggleEnabled}
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
onToggleArchived={handleToggleArchived}
|
||||||
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
|
/>
|
||||||
/>
|
))}
|
||||||
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
|
</CollapsibleContent>
|
||||||
</div>
|
</Collapsible>
|
||||||
) : (
|
))}
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-3 py-2.5">
|
) : null}
|
||||||
{routine.assigneeAgentId ? (() => {
|
|
||||||
const agent = agentById.get(routine.assigneeAgentId);
|
|
||||||
return agent ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">{agent.name}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
|
||||||
);
|
|
||||||
})() : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-muted-foreground">
|
|
||||||
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
|
|
||||||
{routine.lastRun ? (
|
|
||||||
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ToggleSwitch
|
|
||||||
size="lg"
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: nextRoutineStatus(routine.status, !enabled),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={runningRoutineId === routine.id || isArchived}
|
|
||||||
onClick={() => handleRunNow(routine)}
|
|
||||||
>
|
|
||||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: enabled ? "paused" : "active",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
>
|
|
||||||
{enabled ? "Pause" : "Enable"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: routine.status === "archived" ? "active" : "archived",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isStatusPending}
|
|
||||||
>
|
|
||||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RoutineRunVariablesDialog
|
<RoutineRunVariablesDialog
|
||||||
open={runDialogRoutine !== null}
|
open={runDialogRoutine !== null}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue