merge master into pap-1078-qol-fixes

Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-02 13:14:20 -05:00
59 changed files with 16794 additions and 375 deletions
+23
View File
@@ -113,4 +113,27 @@ describe("IssueRow", () => {
root.unmount();
});
});
it("preserves the issue detail breadcrumb source and href in the link target", () => {
const root = createRoot(container);
const issue = createIssue();
const state = {
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
issueDetailSource: "inbox",
};
act(() => {
root.render(<IssueRow issue={issue} issueLinkState={state} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
);
act(() => {
root.unmount();
});
});
});
+69 -25
View File
@@ -26,7 +26,12 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
return "shared_workspace";
}
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
function shouldPresentExistingWorkspaceSelection(issue: {
executionWorkspaceId: string | null;
executionWorkspacePreference: string | null;
executionWorkspaceSettings: Issue["executionWorkspaceSettings"];
currentExecutionWorkspace?: ExecutionWorkspace | null;
}) {
const persistedMode =
issue.currentExecutionWorkspace?.mode
?? issue.executionWorkspaceSettings?.mode
@@ -156,19 +161,44 @@ function statusBadge(status: string) {
/* -------------------------------------------------------------------------- */
interface IssueWorkspaceCardProps {
issue: Issue;
issue: Omit<
Pick<
Issue,
| "companyId"
| "projectId"
| "projectWorkspaceId"
| "executionWorkspaceId"
| "executionWorkspacePreference"
| "executionWorkspaceSettings"
>,
"companyId"
> & {
companyId: string | null;
currentExecutionWorkspace?: ExecutionWorkspace | null;
};
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
onUpdate: (data: Record<string, unknown>) => void;
initialEditing?: boolean;
livePreview?: boolean;
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
}
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
export function IssueWorkspaceCard({
issue,
project,
onUpdate,
initialEditing = false,
livePreview = false,
onDraftChange,
}: IssueWorkspaceCardProps) {
const { selectedCompanyId } = useCompany();
const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(false);
const [editing, setEditing] = useState(initialEditing);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
@@ -209,13 +239,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
?? workspace
?? null;
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
const configuredSelection = shouldPresentExistingWorkspaceSelection(issue)
? "reuse_existing"
: (
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(project)
);
const currentSelection = configuredSelection === "operator_branch" || configuredSelection === "agent_default"
? "shared_workspace"
: configuredSelection;
const [draftSelection, setDraftSelection] = useState(currentSelection);
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
@@ -245,24 +278,33 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
onUpdate({
executionWorkspacePreference: draftSelection,
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
executionWorkspaceSettings: {
mode:
draftSelection === "reuse_existing"
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
: draftSelection,
},
});
setEditing(false);
}, [
canSaveWorkspaceConfig,
const buildWorkspaceDraftUpdate = useCallback(() => ({
executionWorkspacePreference: draftSelection,
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
executionWorkspaceSettings: {
mode:
draftSelection === "reuse_existing"
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
: draftSelection,
},
}), [
configuredReusableWorkspace?.mode,
draftExecutionWorkspaceId,
draftSelection,
]);
useEffect(() => {
if (!onDraftChange) return;
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig });
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]);
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
onUpdate(buildWorkspaceDraftUpdate());
setEditing(false);
}, [
buildWorkspaceDraftUpdate,
canSaveWorkspaceConfig,
onUpdate,
]);
@@ -274,6 +316,8 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
if (!policyEnabled || !project) return null;
const showEditingControls = livePreview || editing;
return (
<div className="rounded-lg border border-border p-3 space-y-2">
{/* Header row */}
@@ -286,7 +330,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
</div>
<div className="flex items-center gap-1">
{editing ? (
{!livePreview && editing ? (
<>
<Button
variant="ghost"
@@ -305,7 +349,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
Save
</Button>
</>
) : (
) : !livePreview ? (
<Button
variant="ghost"
size="sm"
@@ -314,12 +358,12 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
>
<Pencil className="h-3 w-3 mr-1" />Edit
</Button>
)}
) : null}
</div>
</div>
{/* Read-only info */}
{!editing && (
{!showEditingControls && (
<div className="space-y-1.5 text-xs">
{workspace?.branchName && (
<div className="flex items-center gap-1.5">
@@ -377,7 +421,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
)}
{/* Editing controls */}
{editing && (
{showEditingControls && (
<div className="space-y-2 pt-1">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
+55
View File
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "./StatusIcon";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { timeAgo } from "../lib/timeAgo";
interface IssuesQuicklookProps {
issue: Issue;
children: React.ReactNode;
}
export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
asChild
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{children}
</PopoverTrigger>
<PopoverContent
className="w-64 p-3"
side="top"
align="start"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-2">
<div className="flex items-start gap-2">
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<Link
to={createIssueDetailPath(issue.identifier ?? issue.id)}
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
>
{issue.title}
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span>·</span>
<span>{issue.status.replace(/_/g, " ")}</span>
<span>·</span>
<span>{timeAgo(new Date(issue.updatedAt))}</span>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+12 -3
View File
@@ -17,6 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
@@ -24,6 +25,7 @@ import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
@@ -85,6 +87,10 @@ export function Layout() {
},
refetchIntervalInBackground: true,
});
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
@@ -141,6 +147,7 @@ export function Layout() {
useCompanyPageMemory();
useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
@@ -259,12 +266,13 @@ export function Layout() {
}, [location.hash, location.pathname, location.search]);
return (
<div
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)}
>
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -436,6 +444,7 @@ export function Layout() {
<NewGoalDialog />
<NewAgentDialog />
<ToastViewport />
</div>
</div>
</GeneralSettingsProvider>
);
}
+1
View File
@@ -340,6 +340,7 @@ export function NewIssueDialog() {
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: newIssueOpen,
retry: false,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
+1
View File
@@ -242,6 +242,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const linkedGoalIds = project.goalIds.length > 0
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
let issueWorkspaceDraftCalls = 0;
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: {
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: true })),
},
}));
vi.mock("./IssueWorkspaceCard", async () => {
const React = await import("react");
return {
IssueWorkspaceCard: ({
onDraftChange,
}: {
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
}) => {
React.useEffect(() => {
issueWorkspaceDraftCalls += 1;
if (issueWorkspaceDraftCalls > 20) {
throw new Error("IssueWorkspaceCard onDraftChange looped");
}
onDraftChange?.({
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
}, { canSave: true });
}, [onDraftChange]);
return <div data-testid="workspace-card">Workspace card</div>;
},
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createProject(): Project {
return {
id: "project-1",
companyId: "company-1",
urlKey: "workspace-project",
goalId: null,
goalIds: [],
goals: [],
name: "Workspace project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
},
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/paperclip/project-1",
effectiveLocalFolder: "/tmp/paperclip/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
};
}
describe("RoutineRunVariablesDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
issueWorkspaceDraftCalls = 0;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("does not loop when the workspace card reports the same draft repeatedly", async () => {
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"
project={createProject()}
variables={[]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
await Promise.resolve();
await Promise.resolve();
});
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
expect(document.body.textContent).toContain("Run routine");
await act(async () => {
root.unmount();
});
});
});
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
function buildInitialValues(variables: RoutineVariable[]) {
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
}
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
}
return "shared_workspace";
}
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
return {
executionWorkspaceId: null as string | null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
};
}
function workspaceConfigEquals(
a: ReturnType<typeof buildInitialWorkspaceConfig>,
b: ReturnType<typeof buildInitialWorkspaceConfig>,
) {
return a.executionWorkspaceId === b.executionWorkspaceId
&& a.executionWorkspacePreference === b.executionWorkspacePreference
&& a.projectWorkspaceId === b.projectWorkspaceId
&& JSON.stringify(a.executionWorkspaceSettings ?? null) === JSON.stringify(b.executionWorkspaceSettings ?? null);
}
function applyWorkspaceDraft(
current: ReturnType<typeof buildInitialWorkspaceConfig>,
data: Record<string, unknown>,
) {
const next = {
...current,
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
executionWorkspacePreference:
(data.executionWorkspacePreference as string | null | undefined)
?? current.executionWorkspacePreference,
executionWorkspaceSettings:
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
?? current.executionWorkspaceSettings,
};
return workspaceConfigEquals(current, next) ? current : next;
}
function isMissingRequiredValue(value: unknown) {
return value == null || (typeof value === "string" && value.trim().length === 0);
}
function supportsRoutineRunWorkspaceSelection(
project: Project | null | undefined,
isolatedWorkspacesEnabled: boolean,
) {
return isolatedWorkspacesEnabled && Boolean(project?.executionWorkspacePolicy?.enabled);
}
export function routineRunNeedsConfiguration(input: {
variables: RoutineVariable[];
project: Project | null | undefined;
isolatedWorkspacesEnabled: boolean;
}) {
return input.variables.length > 0
|| supportsRoutineRunWorkspaceSelection(input.project, input.isolatedWorkspacesEnabled);
}
export interface RoutineRunDialogSubmitData {
variables?: Record<string, string | number | boolean>;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
}
export function RoutineRunVariablesDialog({
open,
onOpenChange,
companyId,
project,
variables,
isPending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
companyId: string | null | undefined;
project: Project | null | undefined;
variables: RoutineVariable[];
isPending: boolean;
onSubmit: (data: RoutineRunDialogSubmitData) => void;
}) {
const [values, setValues] = useState<Record<string, unknown>>({});
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project));
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
project,
experimentalSettings?.enableIsolatedWorkspaces === true,
);
useEffect(() => {
if (!open) return;
setValues(buildInitialValues(variables));
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
setWorkspaceConfigValid(true);
}, [open, project, variables]);
const missingRequired = useMemo(
() =>
variables
.filter((variable) => variable.required)
.filter((variable) => isMissingRequiredValue(values[variable.name]))
.map((variable) => variable.label || variable.name),
[values, variables],
);
const workspaceIssue = useMemo(() => ({
companyId: companyId ?? null,
projectId: project?.id ?? null,
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
currentExecutionWorkspace: null,
}), [
companyId,
project?.id,
workspaceConfig.executionWorkspaceId,
workspaceConfig.executionWorkspacePreference,
workspaceConfig.executionWorkspaceSettings,
workspaceConfig.projectWorkspaceId,
]);
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid);
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
}, []);
const handleWorkspaceDraftChange = useCallback((
data: Record<string, unknown>,
meta: { canSave: boolean },
) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
}, []);
return (
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Run routine</DialogTitle>
<DialogDescription>
Fill in the routine variables before starting the execution issue.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{variables.map((variable) => (
<div key={variable.name} className="space-y-1.5">
<Label className="text-xs">
{variable.label || variable.name}
{variable.required ? " *" : ""}
</Label>
{variable.type === "textarea" ? (
<Textarea
rows={4}
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
/>
) : variable.type === "boolean" ? (
<Select
value={values[variable.name] === true ? "true" : values[variable.name] === false ? "false" : "__unset__"}
onValueChange={(next) => setValues((current) => ({
...current,
[variable.name]: next === "__unset__" ? "" : next === "true",
}))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No value</SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : variable.type === "select" ? (
<Select
value={typeof values[variable.name] === "string" && values[variable.name] ? values[variable.name] as string : "__unset__"}
onValueChange={(next) => setValues((current) => ({
...current,
[variable.name]: next === "__unset__" ? "" : next,
}))}
>
<SelectTrigger>
<SelectValue placeholder="Choose a value" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No value</SelectItem>
{variable.options.map((option) => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={variable.type === "number" ? "number" : "text"}
value={values[variable.name] == null ? "" : String(values[variable.name])}
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
/>
)}
</div>
))}
{workspaceSelectionEnabled && project && companyId ? (
<IssueWorkspaceCard
key={`${open ? "open" : "closed"}:${project.id}`}
issue={workspaceIssue}
project={project}
initialEditing
livePreview
onUpdate={handleWorkspaceUpdate}
onDraftChange={handleWorkspaceDraftChange}
/>
) : null}
</div>
<DialogFooter showCloseButton={false}>
{missingRequired.length > 0 ? (
<p className="mr-auto text-xs text-amber-600">
Missing: {missingRequired.join(", ")}
</p>
) : workspaceSelectionEnabled && !workspaceConfigValid ? (
<p className="mr-auto text-xs text-amber-600">
Choose an existing workspace before running.
</p>
) : (
<span className="mr-auto" />
)}
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => {
const nextVariables: Record<string, string | number | boolean> = {};
for (const variable of variables) {
const rawValue = values[variable.name];
if (isMissingRequiredValue(rawValue)) continue;
if (variable.type === "number") {
nextVariables[variable.name] = Number(rawValue);
} else if (variable.type === "boolean") {
nextVariables[variable.name] = rawValue === true;
} else {
nextVariables[variable.name] = String(rawValue);
}
}
onSubmit({
variables: nextVariables,
...(workspaceSelectionEnabled
? {
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
}
: {}),
});
}}
disabled={isPending || !canSubmit}
>
{isPending ? "Running..." : "Run routine"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"];
function serializeVariables(value: RoutineVariable[]) {
return JSON.stringify(value);
}
function parseSelectOptions(value: string) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function updateVariableList(
variables: RoutineVariable[],
name: string,
mutate: (variable: RoutineVariable) => RoutineVariable,
) {
return variables.map((variable) => (variable.name === name ? mutate(variable) : variable));
}
export function RoutineVariablesEditor({
description,
value,
onChange,
}: {
description: string;
value: RoutineVariable[];
onChange: (value: RoutineVariable[]) => void;
}) {
const [open, setOpen] = useState(true);
const syncedVariables = useMemo(
() => syncRoutineVariablesWithTemplate(description, value),
[description, value],
);
const syncedSignature = serializeVariables(syncedVariables);
const currentSignature = serializeVariables(value);
useEffect(() => {
if (syncedSignature !== currentSignature) {
onChange(syncedVariables);
}
}, [currentSignature, onChange, syncedSignature, syncedVariables]);
if (syncedVariables.length === 0) {
return null;
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
<div>
<p className="text-sm font-medium">Variables</p>
<p className="text-xs text-muted-foreground">
Detected from `{"{{name}}"}` placeholders in the routine instructions.
</p>
</div>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{syncedVariables.map((variable) => (
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{`{{${variable.name}}}`}
</Badge>
<span className="text-xs text-muted-foreground">
Prompt the user for this value before each manual run.
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Label</Label>
<Input
value={variable.label ?? ""}
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
label: event.target.value || null,
})))}
placeholder={variable.name.replaceAll("_", " ")}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Select
value={variable.type}
onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
type: type as RoutineVariable["type"],
defaultValue: type === "boolean" ? null : current.defaultValue,
options: type === "select" ? current.options : [],
})))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{variableTypes.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 md:col-span-2">
<div className="flex items-center justify-between gap-3">
<Label className="text-xs">Default value</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={variable.required}
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
required: event.target.checked,
})))}
/>
Required
</label>
</div>
{variable.type === "textarea" ? (
<Textarea
rows={3}
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
defaultValue: event.target.value || null,
})))}
/>
) : variable.type === "boolean" ? (
<Select
value={variable.defaultValue === true ? "true" : variable.defaultValue === false ? "false" : "__unset__"}
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
defaultValue: next === "__unset__" ? null : next === "true",
})))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No default</SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : variable.type === "select" ? (
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Options</Label>
<Input
value={variable.options.join(", ")}
onChange={(event) => {
const options = parseSelectOptions(event.target.value);
onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
options,
defaultValue:
typeof current.defaultValue === "string" && options.includes(current.defaultValue)
? current.defaultValue
: null,
})));
}}
placeholder="high, medium, low"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Default option</Label>
<Select
value={typeof variable.defaultValue === "string" ? variable.defaultValue : "__unset__"}
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
defaultValue: next === "__unset__" ? null : next,
})))}
>
<SelectTrigger>
<SelectValue placeholder="No default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">No default</SelectItem>
{variable.options.map((option) => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<Input
type={variable.type === "number" ? "number" : "text"}
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
defaultValue: event.target.value || null,
})))}
placeholder={variable.type === "number" ? "42" : "Default value"}
/>
)}
</div>
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
);
}
export function RoutineVariablesHint() {
return (
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
</div>
);
}