mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +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
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -16,8 +16,13 @@ import { executionWorkspacesApi } from "../api/execution-workspaces";
|
|||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
type RoutineRunDialogSubmitData,
|
||||
} from "../components/RoutineRunVariablesDialog";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeControls,
|
||||
|
|
@ -25,9 +30,14 @@ import {
|
|||
} from "../components/WorkspaceRuntimeControls";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
import {
|
||||
getWorkspaceSpecificRoutineVariableNames,
|
||||
routineHasWorkspaceSpecificVariables,
|
||||
} from "../lib/workspace-routines";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
|
|
@ -43,7 +53,7 @@ type WorkspaceFormState = {
|
|||
workspaceRuntime: string;
|
||||
};
|
||||
|
||||
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues";
|
||||
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines";
|
||||
|
||||
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
||||
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;
|
||||
const tab = segments[executionWorkspacesIndex + 2];
|
||||
if (tab === "issues") return "issues";
|
||||
if (tab === "routines") return "routines";
|
||||
if (tab === "runtime-logs") return "runtime_logs";
|
||||
if (tab === "configuration") return "configuration";
|
||||
return null;
|
||||
|
|
@ -80,6 +91,10 @@ function formatJson(value: Record<string, unknown> | null | undefined) {
|
|||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formatOptionalDateTime(value: Date | string | null | undefined) {
|
||||
return value ? formatDateTime(value) : "Never";
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
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() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const location = useLocation();
|
||||
|
|
@ -469,7 +666,12 @@ export function ExecutionWorkspaceDetail() {
|
|||
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -570,6 +772,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "runtime_logs", label: "Runtime logs" },
|
||||
{ value: "issues", label: "Issues" },
|
||||
{ value: "routines", label: "Routines" },
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab ?? "configuration"}
|
||||
|
|
@ -932,7 +1135,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
) : activeTab === "issues" ? (
|
||||
<ExecutionWorkspaceIssuesList
|
||||
companyId={workspace.companyId}
|
||||
workspaceId={workspace.id}
|
||||
|
|
@ -941,6 +1144,11 @@ export function ExecutionWorkspaceDetail() {
|
|||
error={linkedIssuesQuery.error as Error | null}
|
||||
project={project}
|
||||
/>
|
||||
) : (
|
||||
<ExecutionWorkspaceRoutinesList
|
||||
workspace={workspace}
|
||||
project={project}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue