[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:
Dotta 2026-05-01 11:58:15 -05:00 committed by GitHub
parent 570a4206da
commit 2d72292ad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 707 additions and 49 deletions

View file

@ -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