mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
[codex] Add workspace routine run tab (#4958)
## Thinking Path > - Paperclip orchestrates AI agents through reusable execution workspaces and routines > - Operators need a fast way to run workspace-aware routines against a specific execution workspace > - The existing workspace detail surface showed configuration, runtime logs, and linked issues, but not routines that depend on workspace variables > - Routine runs also needed to prefill the selected execution workspace so branch variables resolve correctly > - This pull request adds a workspace routines tab and prefilled routine-run dialog support > - The benefit is a tighter workflow for rerunning reviews, smoke checks, and other workspace-specific routines ## What Changed - Added an execution workspace `Routines` tab and company-prefixed routes. - Listed routines that declare or reference workspace-specific variables. - Added `Run now` support that preselects the current execution workspace in `RoutineRunVariablesDialog`. - Centralized reusable execution workspace ordering/deduplication for issue creation and workspace cards. - Added focused UI helper and dialog regression tests. ## Verification - `pnpm exec vitest run ui/src/lib/reusable-execution-workspaces.test.ts ui/src/lib/workspace-routines.test.ts ui/src/components/RoutineRunVariablesDialog.test.tsx ui/src/lib/company-routes.test.ts` - Screenshots were not captured in this PR split; the visible flow is covered by focused component/helper tests and should get browser QA in the follow-up issue. ## Risks - Medium risk: this adds a new workspace detail tab and routine-run path. It is isolated to workspace-scoped routines and uses existing routine run APIs. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool use and local command execution. Exact context window was not exposed in the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
570a4206da
commit
2d72292ad6
17 changed files with 707 additions and 49 deletions
|
|
@ -145,6 +145,7 @@ describe("routine routes", () => {
|
||||||
registerModuleMocks();
|
registerModuleMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
|
mockRoutineService.list.mockResolvedValue([routine]);
|
||||||
mockRoutineService.create.mockResolvedValue(routine);
|
mockRoutineService.create.mockResolvedValue(routine);
|
||||||
mockRoutineService.get.mockResolvedValue(routine);
|
mockRoutineService.get.mockResolvedValue(routine);
|
||||||
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||||
|
|
@ -158,6 +159,23 @@ describe("routine routes", () => {
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes project filters to the routine list service", async () => {
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/companies/${companyId}/routines`)
|
||||||
|
.query({ projectId });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId });
|
||||||
|
});
|
||||||
|
|
||||||
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,39 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("filters listed routines by project", async () => {
|
||||||
|
const { companyId, agentId, projectId, routine, svc } = await seedFixture();
|
||||||
|
const otherProjectId = randomUUID();
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: otherProjectId,
|
||||||
|
companyId,
|
||||||
|
name: "Other routines",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
const otherRoutine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId: otherProjectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "other project routine",
|
||||||
|
description: null,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectRoutines = await svc.list(companyId, { projectId });
|
||||||
|
const allRoutines = await svc.list(companyId);
|
||||||
|
|
||||||
|
expect(projectRoutines.map((entry) => entry.id)).toEqual([routine.id]);
|
||||||
|
expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id]));
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
||||||
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
||||||
const previousRunId = randomUUID();
|
const previousRunId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ export function routineRoutes(
|
||||||
router.get("/companies/:companyId/routines", async (req, res) => {
|
router.get("/companies/:companyId/routines", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const result = await svc.list(companyId);
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||||
|
const result = await svc.list(companyId, { projectId });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1071,11 +1071,17 @@ export function routineService(
|
||||||
get: getRoutineById,
|
get: getRoutineById,
|
||||||
getTrigger: getTriggerById,
|
getTrigger: getTriggerById,
|
||||||
|
|
||||||
list: async (companyId: string): Promise<RoutineListItem[]> => {
|
list: async (
|
||||||
|
companyId: string,
|
||||||
|
filters?: { projectId?: string | null },
|
||||||
|
): Promise<RoutineListItem[]> => {
|
||||||
|
const conditions = [eq(routines.companyId, companyId)];
|
||||||
|
if (filters?.projectId) conditions.push(eq(routines.projectId, filters.projectId));
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(routines)
|
.from(routines)
|
||||||
.where(eq(routines.companyId, companyId))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(routines.updatedAt), asc(routines.title));
|
.orderBy(desc(routines.updatedAt), asc(routines.title));
|
||||||
const routineIds = rows.map((row) => row.id);
|
const routineIds = rows.map((row) => row.id);
|
||||||
const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine] = await Promise.all([
|
const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine] = await Promise.all([
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ function boardRoutes() {
|
||||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||||
|
|
@ -306,6 +307,7 @@ export function App() {
|
||||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
{boardRoutes()}
|
{boardRoutes()}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ export interface RotateRoutineTriggerResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routinesApi = {
|
export const routinesApi = {
|
||||||
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
|
list: (companyId: string, filters?: { projectId?: string | null }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||||
|
const query = params.toString();
|
||||||
|
return api.get<RoutineListItem[]>(`/companies/${companyId}/routines${query ? `?${query}` : ""}`);
|
||||||
|
},
|
||||||
create: (companyId: string, data: Record<string, unknown>) =>
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Routine>(`/companies/${companyId}/routines`, data),
|
api.post<Routine>(`/companies/${companyId}/routines`, data),
|
||||||
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
|
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { environmentsApi } from "../api/environments";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||||
|
|
@ -237,16 +238,7 @@ export function IssueWorkspaceCard({
|
||||||
});
|
});
|
||||||
|
|
||||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
|
||||||
const seen = new Map<string, typeof workspaces[number]>();
|
|
||||||
for (const ws of workspaces) {
|
|
||||||
const key = ws.cwd ?? ws.id;
|
|
||||||
const existing = seen.get(key);
|
|
||||||
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
|
||||||
seen.set(key, ws);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seen.values());
|
|
||||||
}, [reusableExecutionWorkspaces]);
|
}, [reusableExecutionWorkspaces]);
|
||||||
|
|
||||||
const selectedReusableExecutionWorkspace =
|
const selectedReusableExecutionWorkspace =
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { authApi } from "../api/auth";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
|
|
@ -1003,16 +1004,7 @@ export function NewIssueDialog() {
|
||||||
: null;
|
: null;
|
||||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
|
||||||
const seen = new Map<string, typeof workspaces[number]>();
|
|
||||||
for (const ws of workspaces) {
|
|
||||||
const key = ws.cwd ?? ws.id;
|
|
||||||
const existing = seen.get(key);
|
|
||||||
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
|
||||||
seen.set(key, ws);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seen.values());
|
|
||||||
}, [reusableExecutionWorkspaces]);
|
}, [reusableExecutionWorkspaces]);
|
||||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Agent, Project } from "@paperclipai/shared";
|
import type { Agent, ExecutionWorkspace, Project } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@ let issueWorkspaceDraft = {
|
||||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
};
|
};
|
||||||
let issueWorkspaceBranchName: string | null = null;
|
let issueWorkspaceBranchName: string | null = null;
|
||||||
|
let latestWorkspaceIssue: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
vi.mock("../api/instanceSettings", () => ({
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
instanceSettingsApi: {
|
instanceSettingsApi: {
|
||||||
|
|
@ -26,14 +27,17 @@ vi.mock("./IssueWorkspaceCard", async () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
IssueWorkspaceCard: ({
|
IssueWorkspaceCard: ({
|
||||||
|
issue,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
}: {
|
}: {
|
||||||
|
issue: Record<string, unknown>;
|
||||||
onDraftChange?: (
|
onDraftChange?: (
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
meta: { canSave: boolean; workspaceBranchName?: string | null },
|
meta: { canSave: boolean; workspaceBranchName?: string | null },
|
||||||
) => void;
|
) => void;
|
||||||
}) => {
|
}) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
latestWorkspaceIssue = issue;
|
||||||
issueWorkspaceDraftCalls += 1;
|
issueWorkspaceDraftCalls += 1;
|
||||||
if (issueWorkspaceDraftCalls > 20) {
|
if (issueWorkspaceDraftCalls > 20) {
|
||||||
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
||||||
|
|
@ -120,6 +124,43 @@ function createAgent(): Agent {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createExecutionWorkspace(): ExecutionWorkspace {
|
||||||
|
return {
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "project-workspace-1",
|
||||||
|
sourceIssueId: null,
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "PAP-1634",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/paperclip/PAP-1634",
|
||||||
|
repoUrl: null,
|
||||||
|
baseRef: "main",
|
||||||
|
branchName: "pap-1634-routine-branch",
|
||||||
|
providerType: "local_fs",
|
||||||
|
providerRef: null,
|
||||||
|
derivedFromExecutionWorkspaceId: null,
|
||||||
|
lastUsedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
openedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
closedAt: null,
|
||||||
|
cleanupEligibleAt: null,
|
||||||
|
cleanupReason: null,
|
||||||
|
config: {
|
||||||
|
provisionCommand: null,
|
||||||
|
teardownCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
workspaceRuntime: null,
|
||||||
|
desiredState: null,
|
||||||
|
},
|
||||||
|
metadata: null,
|
||||||
|
runtimeServices: [],
|
||||||
|
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("RoutineRunVariablesDialog", () => {
|
describe("RoutineRunVariablesDialog", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -133,6 +174,7 @@ describe("RoutineRunVariablesDialog", () => {
|
||||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
};
|
};
|
||||||
issueWorkspaceBranchName = null;
|
issueWorkspaceBranchName = null;
|
||||||
|
latestWorkspaceIssue = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -264,4 +306,63 @@ describe("RoutineRunVariablesDialog", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefills the supplied execution workspace for workspace-specific routine runs", async () => {
|
||||||
|
const workspace = createExecutionWorkspace();
|
||||||
|
issueWorkspaceDraft = {
|
||||||
|
executionWorkspaceId: workspace.id,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
};
|
||||||
|
issueWorkspaceBranchName = workspace.branchName;
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
companyId="company-1"
|
||||||
|
projects={[createProject()]}
|
||||||
|
agents={[createAgent()]}
|
||||||
|
defaultProjectId="project-1"
|
||||||
|
defaultAssigneeAgentId="agent-1"
|
||||||
|
defaultExecutionWorkspace={workspace}
|
||||||
|
variables={[]}
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 10 && latestWorkspaceIssue === null; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(latestWorkspaceIssue).toMatchObject({
|
||||||
|
executionWorkspaceId: workspace.id,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
currentExecutionWorkspace: workspace,
|
||||||
|
projectWorkspaceId: workspace.projectWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
type Agent,
|
type Agent,
|
||||||
|
type ExecutionWorkspace,
|
||||||
|
type ExecutionWorkspaceMode,
|
||||||
type IssueExecutionWorkspaceSettings,
|
type IssueExecutionWorkspaceSettings,
|
||||||
type Project,
|
type Project,
|
||||||
type RoutineVariable,
|
type RoutineVariable,
|
||||||
|
|
@ -56,7 +58,7 @@ function defaultProjectWorkspaceIdForProject(project: Project | null | undefined
|
||||||
?? null;
|
?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) {
|
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined): ExecutionWorkspaceMode {
|
||||||
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||||
if (
|
if (
|
||||||
defaultMode === "isolated_workspace" ||
|
defaultMode === "isolated_workspace" ||
|
||||||
|
|
@ -68,19 +70,60 @@ function defaultExecutionWorkspaceModeForProject(project: Project | null | undef
|
||||||
return "shared_workspace";
|
return "shared_workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
|
function issueModeForExistingWorkspace(mode: string | null | undefined): ExecutionWorkspaceMode {
|
||||||
|
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
|
||||||
|
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
|
||||||
|
return "shared_workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueWorkspacePreferenceFromDraft(value: unknown, fallback: ExecutionWorkspaceMode): ExecutionWorkspaceMode {
|
||||||
|
if (
|
||||||
|
value === "inherit" ||
|
||||||
|
value === "shared_workspace" ||
|
||||||
|
value === "isolated_workspace" ||
|
||||||
|
value === "operator_branch" ||
|
||||||
|
value === "reuse_existing" ||
|
||||||
|
value === "agent_default"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoutineRunWorkspaceConfig = {
|
||||||
|
executionWorkspaceId: string | null;
|
||||||
|
executionWorkspacePreference: ExecutionWorkspaceMode;
|
||||||
|
executionWorkspaceSettings: IssueExecutionWorkspaceSettings;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildInitialWorkspaceConfig(
|
||||||
|
project: Project | null | undefined,
|
||||||
|
defaultExecutionWorkspace?: ExecutionWorkspace | null,
|
||||||
|
): RoutineRunWorkspaceConfig {
|
||||||
|
if (defaultExecutionWorkspace && defaultExecutionWorkspace.projectId === project?.id) {
|
||||||
|
return {
|
||||||
|
executionWorkspaceId: defaultExecutionWorkspace.id,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: {
|
||||||
|
mode: issueModeForExistingWorkspace(defaultExecutionWorkspace.mode),
|
||||||
|
},
|
||||||
|
projectWorkspaceId: defaultExecutionWorkspace.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(project),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
|
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
|
||||||
return {
|
return {
|
||||||
executionWorkspaceId: null as string | null,
|
executionWorkspaceId: null as string | null,
|
||||||
executionWorkspacePreference: defaultMode,
|
executionWorkspacePreference: defaultMode,
|
||||||
executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings,
|
executionWorkspaceSettings: { mode: defaultMode },
|
||||||
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
|
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceConfigEquals(
|
function workspaceConfigEquals(
|
||||||
a: ReturnType<typeof buildInitialWorkspaceConfig>,
|
a: RoutineRunWorkspaceConfig,
|
||||||
b: ReturnType<typeof buildInitialWorkspaceConfig>,
|
b: RoutineRunWorkspaceConfig,
|
||||||
) {
|
) {
|
||||||
return a.executionWorkspaceId === b.executionWorkspaceId
|
return a.executionWorkspaceId === b.executionWorkspaceId
|
||||||
&& a.executionWorkspacePreference === b.executionWorkspacePreference
|
&& a.executionWorkspacePreference === b.executionWorkspacePreference
|
||||||
|
|
@ -89,15 +132,16 @@ function workspaceConfigEquals(
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWorkspaceDraft(
|
function applyWorkspaceDraft(
|
||||||
current: ReturnType<typeof buildInitialWorkspaceConfig>,
|
current: RoutineRunWorkspaceConfig,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const next = {
|
const next = {
|
||||||
...current,
|
...current,
|
||||||
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
|
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
|
||||||
executionWorkspacePreference:
|
executionWorkspacePreference: issueWorkspacePreferenceFromDraft(
|
||||||
(data.executionWorkspacePreference as string | null | undefined)
|
data.executionWorkspacePreference,
|
||||||
?? current.executionWorkspacePreference,
|
current.executionWorkspacePreference,
|
||||||
|
),
|
||||||
executionWorkspaceSettings:
|
executionWorkspaceSettings:
|
||||||
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
|
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
|
||||||
?? current.executionWorkspaceSettings,
|
?? current.executionWorkspaceSettings,
|
||||||
|
|
@ -143,6 +187,7 @@ export function RoutineRunVariablesDialog({
|
||||||
agents,
|
agents,
|
||||||
defaultProjectId,
|
defaultProjectId,
|
||||||
defaultAssigneeAgentId,
|
defaultAssigneeAgentId,
|
||||||
|
defaultExecutionWorkspace,
|
||||||
variables,
|
variables,
|
||||||
isPending,
|
isPending,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -155,6 +200,7 @@ export function RoutineRunVariablesDialog({
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
defaultProjectId?: string | null;
|
defaultProjectId?: string | null;
|
||||||
defaultAssigneeAgentId?: string | null;
|
defaultAssigneeAgentId?: string | null;
|
||||||
|
defaultExecutionWorkspace?: ExecutionWorkspace | null;
|
||||||
variables: RoutineVariable[];
|
variables: RoutineVariable[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
||||||
|
|
@ -193,7 +239,8 @@ export function RoutineRunVariablesDialog({
|
||||||
const currentAssignee = selection.assigneeAgentId
|
const currentAssignee = selection.assigneeAgentId
|
||||||
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
|
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
const [workspaceConfig, setWorkspaceConfig] = useState(() =>
|
||||||
|
buildInitialWorkspaceConfig(selectedProject, defaultExecutionWorkspace));
|
||||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||||
const [workspaceBranchName, setWorkspaceBranchName] = useState<string | null>(null);
|
const [workspaceBranchName, setWorkspaceBranchName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -213,10 +260,13 @@ export function RoutineRunVariablesDialog({
|
||||||
setValues(buildInitialValues(variables));
|
setValues(buildInitialValues(variables));
|
||||||
const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
|
const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
|
||||||
setSelection(nextSelection);
|
setSelection(nextSelection);
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
setWorkspaceConfig(buildInitialWorkspaceConfig(
|
||||||
|
projects.find((project) => project.id === nextSelection.projectId) ?? null,
|
||||||
|
defaultExecutionWorkspace,
|
||||||
|
));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
setWorkspaceBranchName(null);
|
setWorkspaceBranchName(defaultExecutionWorkspace?.branchName ?? null);
|
||||||
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
}, [defaultAssigneeAgentId, defaultExecutionWorkspace, defaultProjectId, open, projects, variables]);
|
||||||
|
|
||||||
const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName
|
const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName
|
||||||
? workspaceBranchName
|
? workspaceBranchName
|
||||||
|
|
@ -245,9 +295,13 @@ export function RoutineRunVariablesDialog({
|
||||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||||
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||||
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
||||||
currentExecutionWorkspace: null,
|
currentExecutionWorkspace:
|
||||||
|
workspaceConfig.executionWorkspaceId && workspaceConfig.executionWorkspaceId === defaultExecutionWorkspace?.id
|
||||||
|
? defaultExecutionWorkspace
|
||||||
|
: null,
|
||||||
}), [
|
}), [
|
||||||
companyId,
|
companyId,
|
||||||
|
defaultExecutionWorkspace,
|
||||||
selectedProject?.id,
|
selectedProject?.id,
|
||||||
workspaceConfig.executionWorkspaceId,
|
workspaceConfig.executionWorkspaceId,
|
||||||
workspaceConfig.executionWorkspacePreference,
|
workspaceConfig.executionWorkspacePreference,
|
||||||
|
|
@ -271,10 +325,13 @@ export function RoutineRunVariablesDialog({
|
||||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||||
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
||||||
setWorkspaceBranchName((current) => {
|
setWorkspaceBranchName((current) => {
|
||||||
const next = meta.workspaceBranchName ?? null;
|
const defaultWorkspaceBranchName = defaultExecutionWorkspace?.branchName ?? null;
|
||||||
|
const next = meta.workspaceBranchName
|
||||||
|
?? (data.executionWorkspaceId === defaultExecutionWorkspace?.id ? defaultWorkspaceBranchName : null)
|
||||||
|
?? null;
|
||||||
return current === next ? current : next;
|
return current === next ? current : next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [defaultExecutionWorkspace]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||||
|
|
@ -349,9 +406,13 @@ export function RoutineRunVariablesDialog({
|
||||||
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
||||||
if (projectId) trackRecentProject(projectId);
|
if (projectId) trackRecentProject(projectId);
|
||||||
setSelection((current) => ({ ...current, projectId }));
|
setSelection((current) => ({ ...current, projectId }));
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
setWorkspaceConfig(buildInitialWorkspaceConfig(project, defaultExecutionWorkspace));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
setWorkspaceBranchName(null);
|
setWorkspaceBranchName(
|
||||||
|
defaultExecutionWorkspace && defaultExecutionWorkspace.projectId === project?.id
|
||||||
|
? defaultExecutionWorkspace.branchName
|
||||||
|
: null,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderTriggerValue={(option) =>
|
renderTriggerValue={(option) =>
|
||||||
option && selectedProject ? (
|
option && selectedProject ? (
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,22 @@ import {
|
||||||
describe("company routes", () => {
|
describe("company routes", () => {
|
||||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||||
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/routines")).toBe(true);
|
||||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||||
"/PAP/execution-workspaces/workspace-123",
|
"/PAP/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/routines", "PAP")).toBe(
|
||||||
|
"/PAP/execution-workspaces/workspace-123/routines",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||||
"/execution-workspaces/workspace-123",
|
"/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/routines")).toBe(
|
||||||
|
"/execution-workspaces/workspace-123/routines",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ export const queryKeys = {
|
||||||
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
||||||
},
|
},
|
||||||
routines: {
|
routines: {
|
||||||
list: (companyId: string) => ["routines", companyId] as const,
|
list: (companyId: string, filters?: { projectId?: string | null }) =>
|
||||||
|
["routines", companyId, filters?.projectId ?? "__all-projects__"] as const,
|
||||||
detail: (id: string) => ["routines", "detail", id] as const,
|
detail: (id: string) => ["routines", "detail", id] as const,
|
||||||
runs: (id: string) => ["routines", "runs", id] as const,
|
runs: (id: string) => ["routines", "runs", id] as const,
|
||||||
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
|
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
|
||||||
|
|
|
||||||
82
ui/src/lib/reusable-execution-workspaces.test.ts
Normal file
82
ui/src/lib/reusable-execution-workspaces.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { orderReusableExecutionWorkspaces, type ReusableExecutionWorkspaceLike } from "./reusable-execution-workspaces";
|
||||||
|
|
||||||
|
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): ReusableExecutionWorkspaceLike {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "workspace-id",
|
||||||
|
name: overrides.name ?? "Workspace",
|
||||||
|
cwd: overrides.cwd ?? null,
|
||||||
|
lastUsedAt: overrides.lastUsedAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orderReusableExecutionWorkspaces", () => {
|
||||||
|
it("puts the most recently used workspace first and sorts the rest alphabetically", () => {
|
||||||
|
const workspaces = [
|
||||||
|
workspace({ id: "charlie", name: "Charlie", lastUsedAt: "2026-01-03T00:00:00.000Z" }),
|
||||||
|
workspace({ id: "zulu", name: "Zulu", lastUsedAt: "2026-01-05T00:00:00.000Z" }),
|
||||||
|
workspace({ id: "alpha", name: "Alpha", lastUsedAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
workspace({ id: "bravo", name: "Bravo", lastUsedAt: "2026-01-04T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
||||||
|
"zulu",
|
||||||
|
"alpha",
|
||||||
|
"bravo",
|
||||||
|
"charlie",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the latest used workspace for duplicate paths before sorting", () => {
|
||||||
|
const workspaces = [
|
||||||
|
workspace({
|
||||||
|
id: "older-duplicate",
|
||||||
|
name: "Older duplicate",
|
||||||
|
cwd: "/tmp/shared",
|
||||||
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
workspace({ id: "beta", name: "Beta", cwd: "/tmp/beta", lastUsedAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
|
workspace({
|
||||||
|
id: "newer-duplicate",
|
||||||
|
name: "Newer duplicate",
|
||||||
|
cwd: "/tmp/shared",
|
||||||
|
lastUsedAt: "2026-01-04T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
workspace({ id: "alpha", name: "Alpha", cwd: "/tmp/alpha", lastUsedAt: "2026-01-03T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
||||||
|
"newer-duplicate",
|
||||||
|
"alpha",
|
||||||
|
"beta",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let updatedAt churn outrank the last used workspace", () => {
|
||||||
|
type WorkspaceWithUpdatedAt = ReusableExecutionWorkspaceLike & { updatedAt: Date | string };
|
||||||
|
const workspaces: WorkspaceWithUpdatedAt[] = [
|
||||||
|
{
|
||||||
|
...workspace({
|
||||||
|
id: "recently-used",
|
||||||
|
name: "Recently used",
|
||||||
|
cwd: "/tmp/shared",
|
||||||
|
lastUsedAt: "2026-01-04T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...workspace({
|
||||||
|
id: "recently-updated",
|
||||||
|
name: "Recently updated",
|
||||||
|
cwd: "/tmp/shared",
|
||||||
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
updatedAt: "2026-01-05T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
||||||
|
"recently-used",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
ui/src/lib/reusable-execution-workspaces.ts
Normal file
49
ui/src/lib/reusable-execution-workspaces.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
export interface ReusableExecutionWorkspaceLike {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string | null;
|
||||||
|
lastUsedAt: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceLastUsedTime(workspace: Pick<ReusableExecutionWorkspaceLike, "lastUsedAt">) {
|
||||||
|
const time = new Date(workspace.lastUsedAt).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareWorkspaceNames(a: ReusableExecutionWorkspaceLike, b: ReusableExecutionWorkspaceLike) {
|
||||||
|
const nameCompare = a.name.localeCompare(b.name, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (nameCompare !== 0) return nameCompare;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderReusableExecutionWorkspaces<T extends ReusableExecutionWorkspaceLike>(
|
||||||
|
workspaces: readonly T[],
|
||||||
|
): T[] {
|
||||||
|
const deduplicatedByPath = new Map<string, T>();
|
||||||
|
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
const key = workspace.cwd ?? workspace.id;
|
||||||
|
const existing = deduplicatedByPath.get(key);
|
||||||
|
if (!existing || workspaceLastUsedTime(workspace) > workspaceLastUsedTime(existing)) {
|
||||||
|
deduplicatedByPath.set(key, workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alphabetized = Array.from(deduplicatedByPath.values()).sort(compareWorkspaceNames);
|
||||||
|
if (alphabetized.length <= 1) return alphabetized;
|
||||||
|
|
||||||
|
let mostRecentlyUsed = alphabetized[0]!;
|
||||||
|
for (const workspace of alphabetized.slice(1)) {
|
||||||
|
if (workspaceLastUsedTime(workspace) > workspaceLastUsedTime(mostRecentlyUsed)) {
|
||||||
|
mostRecentlyUsed = workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
mostRecentlyUsed,
|
||||||
|
...alphabetized.filter((workspace) => workspace.id !== mostRecentlyUsed.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
69
ui/src/lib/workspace-routines.test.ts
Normal file
69
ui/src/lib/workspace-routines.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { RoutineListItem } from "@paperclipai/shared";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getWorkspaceSpecificRoutineVariableNames,
|
||||||
|
routineHasWorkspaceSpecificVariables,
|
||||||
|
} from "./workspace-routines";
|
||||||
|
|
||||||
|
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-30T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
triggers: [],
|
||||||
|
lastRun: null,
|
||||||
|
activeIssue: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace routine helpers", () => {
|
||||||
|
it("matches routines with explicit workspace variables", () => {
|
||||||
|
const routine = createRoutine({
|
||||||
|
variables: [
|
||||||
|
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(routineHasWorkspaceSpecificVariables(routine)).toBe(true);
|
||||||
|
expect(getWorkspaceSpecificRoutineVariableNames(routine)).toEqual(["workspaceBranch"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches routines that reference workspace variables in templates", () => {
|
||||||
|
const routine = createRoutine({
|
||||||
|
title: "Review {{ workspaceBranch }}",
|
||||||
|
description: "Check branch {{workspaceBranch}}",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getWorkspaceSpecificRoutineVariableNames(routine)).toEqual(["workspaceBranch"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores routines with only non-workspace variables", () => {
|
||||||
|
const routine = createRoutine({
|
||||||
|
title: "Review {{repo}}",
|
||||||
|
variables: [
|
||||||
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(routineHasWorkspaceSpecificVariables(routine)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
ui/src/lib/workspace-routines.ts
Normal file
31
ui/src/lib/workspace-routines.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {
|
||||||
|
extractRoutineVariableNames,
|
||||||
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
|
type RoutineListItem,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
const WORKSPACE_SPECIFIC_ROUTINE_VARIABLES = new Set([
|
||||||
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getWorkspaceSpecificRoutineVariableNames(routine: RoutineListItem): string[] {
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
for (const variable of routine.variables) {
|
||||||
|
if (WORKSPACE_SPECIFIC_ROUTINE_VARIABLES.has(variable.name)) {
|
||||||
|
names.add(variable.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of extractRoutineVariableNames([routine.title, routine.description])) {
|
||||||
|
if (WORKSPACE_SPECIFIC_ROUTINE_VARIABLES.has(name)) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routineHasWorkspaceSpecificVariables(routine: RoutineListItem): boolean {
|
||||||
|
return getWorkspaceSpecificRoutineVariableNames(routine).length > 0;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
|
||||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -16,8 +16,13 @@ import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { routinesApi } from "../api/routines";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
|
import {
|
||||||
|
RoutineRunVariablesDialog,
|
||||||
|
type RoutineRunDialogSubmitData,
|
||||||
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceRuntimeControlSections,
|
buildWorkspaceRuntimeControlSections,
|
||||||
WorkspaceRuntimeControls,
|
WorkspaceRuntimeControls,
|
||||||
|
|
@ -25,9 +30,14 @@ import {
|
||||||
} from "../components/WorkspaceRuntimeControls";
|
} from "../components/WorkspaceRuntimeControls";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
getWorkspaceSpecificRoutineVariableNames,
|
||||||
|
routineHasWorkspaceSpecificVariables,
|
||||||
|
} from "../lib/workspace-routines";
|
||||||
|
|
||||||
type WorkspaceFormState = {
|
type WorkspaceFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -43,7 +53,7 @@ type WorkspaceFormState = {
|
||||||
workspaceRuntime: string;
|
workspaceRuntime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues";
|
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines";
|
||||||
|
|
||||||
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
|
@ -51,6 +61,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
|
||||||
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
||||||
const tab = segments[executionWorkspacesIndex + 2];
|
const tab = segments[executionWorkspacesIndex + 2];
|
||||||
if (tab === "issues") return "issues";
|
if (tab === "issues") return "issues";
|
||||||
|
if (tab === "routines") return "routines";
|
||||||
if (tab === "runtime-logs") return "runtime_logs";
|
if (tab === "runtime-logs") return "runtime_logs";
|
||||||
if (tab === "configuration") return "configuration";
|
if (tab === "configuration") return "configuration";
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -80,6 +91,10 @@ function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOptionalDateTime(value: Date | string | null | undefined) {
|
||||||
|
return value ? formatDateTime(value) : "Never";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeText(value: string) {
|
function normalizeText(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
|
@ -305,6 +320,188 @@ function ExecutionWorkspaceIssuesList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceRoutineRow({
|
||||||
|
routine,
|
||||||
|
variableNames,
|
||||||
|
runningRoutineId,
|
||||||
|
onRunNow,
|
||||||
|
}: {
|
||||||
|
routine: RoutineListItem;
|
||||||
|
variableNames: string[];
|
||||||
|
runningRoutineId: string | null;
|
||||||
|
onRunNow: (routine: RoutineListItem) => void;
|
||||||
|
}) {
|
||||||
|
const isArchived = routine.status === "archived";
|
||||||
|
const isRunning = runningRoutineId === routine.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border px-3 py-3 last:border-b-0 sm:flex-row sm:items-center">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link to={`/routines/${routine.id}`} className="truncate text-sm font-medium hover:underline">
|
||||||
|
{routine.title}
|
||||||
|
</Link>
|
||||||
|
{routine.status !== "active" ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{routine.status}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{routine.assigneeAgentId ? "Default agent set" : "Choose agent when running"}</span>
|
||||||
|
<span>Last run {formatOptionalDateTime(routine.lastRun?.triggeredAt ?? routine.lastTriggeredAt)}</span>
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
{variableNames.map((name) => (
|
||||||
|
<span key={name} className="rounded-sm bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={isArchived || isRunning}
|
||||||
|
onClick={() => onRunNow(routine)}
|
||||||
|
>
|
||||||
|
{isRunning ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||||||
|
{isRunning ? "Running..." : "Run now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExecutionWorkspaceRoutinesList({
|
||||||
|
workspace,
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
workspace: ExecutionWorkspace;
|
||||||
|
project: Project | null;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { pushToast } = useToastActions();
|
||||||
|
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||||
|
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: routines, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.routines.list(workspace.companyId, { projectId: workspace.projectId }),
|
||||||
|
queryFn: () => routinesApi.list(workspace.companyId, { projectId: workspace.projectId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(workspace.companyId),
|
||||||
|
queryFn: () => agentsApi.list(workspace.companyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceRoutines = useMemo(
|
||||||
|
() => (routines ?? []).filter(routineHasWorkspaceSpecificVariables),
|
||||||
|
[routines],
|
||||||
|
);
|
||||||
|
|
||||||
|
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: ["routines", workspace.companyId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(workspace.companyId) }),
|
||||||
|
]);
|
||||||
|
pushToast({
|
||||||
|
title: "Routine started",
|
||||||
|
body: "Paperclip created a run using this execution workspace.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="rounded-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Workspace routines</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Routines that use workspace-specific variables can be run against this execution workspace.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading routines...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load routines."}
|
||||||
|
</p>
|
||||||
|
) : workspaceRoutines.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
|
<Repeat className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No routines use workspace-specific variables yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-border">
|
||||||
|
{workspaceRoutines.map((routine) => (
|
||||||
|
<WorkspaceRoutineRow
|
||||||
|
key={routine.id}
|
||||||
|
routine={routine}
|
||||||
|
variableNames={getWorkspaceSpecificRoutineVariableNames(routine)}
|
||||||
|
runningRoutineId={runningRoutineId}
|
||||||
|
onRunNow={setRunDialogRoutine}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open={runDialogRoutine !== null}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setRunDialogRoutine(null);
|
||||||
|
}}
|
||||||
|
companyId={workspace.companyId}
|
||||||
|
routineName={runDialogRoutine?.title ?? null}
|
||||||
|
agents={agents ?? []}
|
||||||
|
projects={project ? [project] : []}
|
||||||
|
defaultProjectId={workspace.projectId}
|
||||||
|
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
|
||||||
|
defaultExecutionWorkspace={workspace}
|
||||||
|
variables={runDialogRoutine?.variables ?? []}
|
||||||
|
isPending={runRoutine.isPending}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
if (!runDialogRoutine) return;
|
||||||
|
runRoutine.mutate({ id: runDialogRoutine.id, data });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExecutionWorkspaceDetail() {
|
export function ExecutionWorkspaceDetail() {
|
||||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -469,7 +666,12 @@ export function ExecutionWorkspaceDetail() {
|
||||||
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
||||||
try {
|
try {
|
||||||
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
|
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
|
||||||
if (storedTab === "issues" || storedTab === "configuration" || storedTab === "runtime_logs") {
|
if (
|
||||||
|
storedTab === "issues" ||
|
||||||
|
storedTab === "routines" ||
|
||||||
|
storedTab === "configuration" ||
|
||||||
|
storedTab === "runtime_logs"
|
||||||
|
) {
|
||||||
cachedTab = storedTab;
|
cachedTab = storedTab;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -570,6 +772,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "runtime_logs", label: "Runtime logs" },
|
{ value: "runtime_logs", label: "Runtime logs" },
|
||||||
{ value: "issues", label: "Issues" },
|
{ value: "issues", label: "Issues" },
|
||||||
|
{ value: "routines", label: "Routines" },
|
||||||
]}
|
]}
|
||||||
align="start"
|
align="start"
|
||||||
value={activeTab ?? "configuration"}
|
value={activeTab ?? "configuration"}
|
||||||
|
|
@ -932,7 +1135,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : activeTab === "issues" ? (
|
||||||
<ExecutionWorkspaceIssuesList
|
<ExecutionWorkspaceIssuesList
|
||||||
companyId={workspace.companyId}
|
companyId={workspace.companyId}
|
||||||
workspaceId={workspace.id}
|
workspaceId={workspace.id}
|
||||||
|
|
@ -941,6 +1144,11 @@ export function ExecutionWorkspaceDetail() {
|
||||||
error={linkedIssuesQuery.error as Error | null}
|
error={linkedIssuesQuery.error as Error | null}
|
||||||
project={project}
|
project={project}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ExecutionWorkspaceRoutinesList
|
||||||
|
workspace={workspace}
|
||||||
|
project={project}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ExecutionWorkspaceCloseDialog
|
<ExecutionWorkspaceCloseDialog
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue