diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index eeda4c95c0..700717c774 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -27,6 +27,7 @@ const mockProjectsApi = vi.hoisted(() => ({ })); const mockExecutionWorkspacesApi = vi.hoisted(() => ({ + list: vi.fn(), controlRuntimeCommands: vi.fn(), })); @@ -462,6 +463,7 @@ describe("IssueProperties", () => { mockAgentsApi.list.mockResolvedValue([]); mockAgentsApi.adapterModels.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); + mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.getDocument.mockResolvedValue(null); @@ -3022,4 +3024,139 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + + it("hides the execution workspace picker without an enabled project policy", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: null })]); + const root = renderProperties(container, { + issue: createIssue({ projectId: "project-1" }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + + await flush(); + + expect(container.querySelector('[data-property-label="Execution"]')).toBeNull(); + expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("shows the workspace picker with no bound workspace", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ + executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" }, + })]); + const root = renderProperties(container, { + issue: createIssue({ projectId: "project-1" }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + + await waitForAssertion(() => { + expect(findRowTrigger(container, "Execution")?.textContent).toBe("Default"); + }); + + act(() => root.unmount()); + }); + + it("saves the exact isolated-workspace payload", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ + executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace" }, + })]); + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ projectId: "project-1" }), + childIssues: [], + onUpdate, + inline: true, + }); + + await waitForAssertion(() => expect(findRowTrigger(container, "Execution")).toBeDefined()); + act(() => findRowTrigger(container, "Execution")!.click()); + const isolatedOption = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("New isolated workspace")); + act(() => isolatedOption!.click()); + + expect(onUpdate).toHaveBeenCalledWith({ + executionWorkspacePreference: "isolated_workspace", + executionWorkspaceId: null, + executionWorkspaceSettings: { + mode: "isolated_workspace", + environmentId: null, + }, + }); + act(() => root.unmount()); + }); + + it("searches reusable workspaces and saves the selected workspace", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockProjectsApi.list.mockResolvedValue([createProject({ + executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace" }, + })]); + const alphaWorkspace = createExecutionWorkspace({ + id: "workspace-alpha", + name: "Alpha workspace", + cwd: "/tmp/paperclip/alpha", + branchName: "alpha-branch", + lastUsedAt: new Date(), + }); + const betaWorkspace = createExecutionWorkspace({ + id: "workspace-beta", + name: "Beta workspace", + cwd: "/tmp/paperclip/beta", + branchName: "beta-branch", + lastUsedAt: new Date(), + }); + mockExecutionWorkspacesApi.list.mockResolvedValue([alphaWorkspace, betaWorkspace]); + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ projectId: "project-1", projectWorkspaceId: "workspace-main" }), + childIssues: [], + onUpdate, + inline: true, + }); + + await waitForAssertion(() => expect(findRowTrigger(container, "Execution")).toBeDefined()); + act(() => findRowTrigger(container, "Execution")!.click()); + const reuseOption = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Reuse existing workspace")); + act(() => reuseOption!.click()); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Recent"); + expect(container.textContent).toContain("Alpha workspace"); + expect(container.textContent).toContain("Beta workspace"); + }); + expect(mockExecutionWorkspacesApi.list).toHaveBeenCalledWith("company-1", { + projectId: "project-1", + projectWorkspaceId: "workspace-main", + reuseEligible: true, + }); + + const search = container.querySelector('input[aria-label="Search reusable workspaces"]') as HTMLInputElement; + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + nativeSetter?.call(search, "Beta"); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).not.toContain("Alpha workspace"); + expect(container.textContent).toContain("Beta workspace"); + + const betaOption = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Beta workspace")); + act(() => betaOption!.click()); + expect(onUpdate).toHaveBeenCalledWith({ + executionWorkspacePreference: "reuse_existing", + executionWorkspaceId: "workspace-beta", + executionWorkspaceSettings: { + mode: "isolated_workspace", + environmentId: null, + }, + }); + + act(() => root.unmount()); + }); }); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index a06b7101cf..8f82c5360f 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -10,8 +10,11 @@ import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; import { defaultExecutionWorkspaceModeForProject, - issueExecutionWorkspaceModeForExistingWorkspace, } from "../lib/project-workspace-defaults"; +import { + buildWorkspaceSelectionUpdate, + currentWorkspaceSelection, +} from "../lib/issue-workspace-selection"; import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { cn, projectWorkspaceUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; @@ -29,22 +32,6 @@ const EXECUTION_WORKSPACE_OPTIONS = [ { value: "reuse_existing", label: "Reuse existing workspace" }, ] as const; -function shouldPresentExistingWorkspaceSelection( - issue: Pick< - Issue, - "executionWorkspaceId" | "executionWorkspacePreference" | "executionWorkspaceSettings" | "currentExecutionWorkspace" - >, -) { - const persistedMode = - issue.currentExecutionWorkspace?.mode - ?? issue.executionWorkspaceSettings?.mode - ?? issue.executionWorkspacePreference; - return Boolean( - issue.executionWorkspaceId && - (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), - ); -} - /* -------------------------------------------------------------------------- */ /* Sub-components */ /* -------------------------------------------------------------------------- */ @@ -189,7 +176,7 @@ interface IssueWorkspaceCardProps { onUpdate: (data: Record) => void; initialEditing?: boolean; livePreview?: boolean; - onDraftChange?: (data: Record, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void; + onDraftChange?: (data: Record | null, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void; /** Opens the workspace file browser sheet. When omitted, the browse row is hidden. */ onBrowseFiles?: () => void; /** Opens the same browser sheet focused for path entry. */ @@ -259,13 +246,8 @@ export function IssueWorkspaceCard({ ?? workspace ?? null; - const currentSelection = shouldPresentExistingWorkspaceSelection(issue) - ? "reuse_existing" - : ( - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(project) - ); + const currentSelection = currentWorkspaceSelection(issue, project) + ?? defaultExecutionWorkspaceModeForProject(project); const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); @@ -313,17 +295,11 @@ export function IssueWorkspaceCard({ ? configuredReusableWorkspace?.branchName ?? null : null; - const buildWorkspaceDraftUpdate = useCallback(() => ({ - executionWorkspacePreference: draftSelection, - executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null, - executionWorkspaceSettings: { - mode: - draftSelection === "reuse_existing" - ? issueExecutionWorkspaceModeForExistingWorkspace(configuredReusableWorkspace?.mode) - : draftSelection, - environmentId: null, - }, - }), [ + const buildWorkspaceDraftUpdate = useCallback(() => buildWorkspaceSelectionUpdate( + draftSelection, + draftExecutionWorkspaceId || null, + configuredReusableWorkspace?.mode, + ), [ configuredReusableWorkspace?.mode, draftExecutionWorkspaceId, draftSelection, @@ -339,7 +315,9 @@ export function IssueWorkspaceCard({ const handleSave = useCallback(() => { if (!canSaveWorkspaceConfig) return; - onUpdate(buildWorkspaceDraftUpdate()); + const update = buildWorkspaceDraftUpdate(); + if (!update) return; + onUpdate(update); setEditing(false); }, [ buildWorkspaceDraftUpdate, @@ -476,7 +454,7 @@ export function IssueWorkspaceCard({ className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" value={draftSelection} onChange={(e) => { - const nextMode = e.target.value; + const nextMode = e.target.value as typeof draftSelection; setDraftSelection(nextMode); if (nextMode !== "reuse_existing") { setDraftExecutionWorkspaceId(""); diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index d6e7b747c1..d897a3d735 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -8,12 +8,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog"; let issueWorkspaceDraftCalls = 0; -let issueWorkspaceDraft = { +let issueWorkspaceDraft: Record | null = { executionWorkspaceId: null as string | null, executionWorkspacePreference: "shared_workspace", executionWorkspaceSettings: { mode: "shared_workspace" }, }; let issueWorkspaceBranchName: string | null = null; +let issueWorkspaceCanSave = true; let latestWorkspaceIssue: Record | null = null; vi.mock("../api/instanceSettings", () => ({ @@ -32,7 +33,7 @@ vi.mock("./IssueWorkspaceCard", async () => { }: { issue: Record; onDraftChange?: ( - data: Record, + data: Record | null, meta: { canSave: boolean; workspaceBranchName?: string | null }, ) => void; }) => { @@ -43,7 +44,7 @@ vi.mock("./IssueWorkspaceCard", async () => { throw new Error("IssueWorkspaceCard onDraftChange looped"); } onDraftChange?.(issueWorkspaceDraft, { - canSave: true, + canSave: issueWorkspaceCanSave, workspaceBranchName: issueWorkspaceBranchName, }); }, [onDraftChange]); @@ -237,6 +238,7 @@ describe("RoutineRunVariablesDialog", () => { executionWorkspaceSettings: { mode: "shared_workspace" }, }; issueWorkspaceBranchName = null; + issueWorkspaceCanSave = true; latestWorkspaceIssue = null; }); @@ -284,6 +286,40 @@ describe("RoutineRunVariablesDialog", () => { }); }); + it("keeps the run disabled while a reusable workspace selection is incomplete", async () => { + issueWorkspaceDraft = null; + issueWorkspaceCanSave = false; + const root = createRoot(container); + const queryClient = createQueryClient(); + + await flushUi(() => { + root.render( + + {}} + companyId="company-1" + projects={[createProject()]} + agents={[createAgent()]} + defaultProjectId="project-1" + defaultAssigneeAgentId="agent-1" + variables={[]} + isPending={false} + onSubmit={() => {}} + /> + , + ); + }); + await flushUi(() => {}); + + expect(document.body.textContent).toContain("Workspace card"); + expect(findRunButton()?.disabled).toBe(true); + + await flushUi(() => { + root.unmount(); + }); + }); + it("keeps the mobile dialog bounded with an internal form scroll region", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index 67f9f18864..ed5c2b1fe9 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -323,15 +323,17 @@ export function RoutineRunVariablesDialog({ }, []); const handleWorkspaceDraftChange = useCallback(( - data: Record, + data: Record | null, meta: { canSave: boolean; workspaceBranchName?: string | null }, ) => { - setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); + if (data) { + setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); + } setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave)); setWorkspaceBranchName((current) => { const defaultWorkspaceBranchName = defaultExecutionWorkspace?.branchName ?? null; const next = meta.workspaceBranchName - ?? (data.executionWorkspaceId === defaultExecutionWorkspace?.id ? defaultWorkspaceBranchName : null) + ?? (data?.executionWorkspaceId === defaultExecutionWorkspace?.id ? defaultWorkspaceBranchName : null) ?? null; return current === next ? current : next; }); diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 050e611269..4d211f3146 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -8,6 +8,7 @@ import { Link } from "@/lib/router"; import { deriveOriginatingActor, isArtifactReviewDocumentKey, + type ExecutionWorkspace, type Issue, type IssueLabel, } from "@paperclipai/shared"; @@ -68,7 +69,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab"; import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab"; -import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore } from "lucide-react"; +import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore, ChevronLeft } from "lucide-react"; import { AgentIcon } from "../AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector"; import { @@ -98,6 +99,15 @@ import { } from "./helpers"; import { PropertyPicker } from "./property-picker"; import { PropertyChip, PropertyRow, PropertySection } from "./primitives"; +import { + buildWorkspaceSelectionUpdate, + currentWorkspaceSelection, +} from "../../lib/issue-workspace-selection"; +import { + buildReusableExecutionWorkspaceOptionGroups, + dedupeReusableExecutionWorkspaces, + reusableWorkspaceOptionMatches, +} from "../../lib/reusable-execution-workspaces"; import { issueReviewPolicyBadge } from "../../lib/review-policy"; import { IssueCasesPanel } from "../IssueCasesPanel"; import { ExpandRelationListButton, RemovableIssueReferencePill } from "./relation-controls"; @@ -287,6 +297,9 @@ export function IssueProperties({ } | null>(null); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); + const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); + const [workspacePickerStep, setWorkspacePickerStep] = useState<"mode" | "reuse">("mode"); + const [workspaceSearch, setWorkspaceSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); const [blockedByExpanded, setBlockedByExpanded] = useState(false); @@ -452,6 +465,69 @@ export function IssueProperties({ ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; const issueProject = issue.project ?? currentProject; + const workspacePickerEligible = experimentalSettings?.enableIsolatedWorkspaces === true + && Boolean(issueProject?.executionWorkspacePolicy?.enabled); + const { + data: reusableExecutionWorkspaces, + isLoading: reusableExecutionWorkspacesLoading, + isError: reusableExecutionWorkspacesError, + } = useQuery({ + queryKey: queryKeys.executionWorkspaces.list(companyId!, { + projectId: issue.projectId ?? undefined, + projectWorkspaceId: issue.projectWorkspaceId ?? undefined, + reuseEligible: true, + }), + queryFn: () => executionWorkspacesApi.list(companyId!, { + projectId: issue.projectId ?? undefined, + projectWorkspaceId: issue.projectWorkspaceId ?? undefined, + reuseEligible: true, + }), + enabled: Boolean(companyId) && Boolean(issue.projectId) && workspacePickerEligible && workspacePickerOpen, + }); + const effectiveWorkspaceSelection = currentWorkspaceSelection(issue, issueProject); + const hasWorkspaceOverride = issue.executionWorkspacePreference != null + || issue.executionWorkspaceSettings != null; + const activeWorkspacePickerMode = effectiveWorkspaceSelection === "reuse_existing" + ? "reuse" + : !hasWorkspaceOverride + ? "default" + : effectiveWorkspaceSelection === "isolated_workspace" + ? "isolated" + : "default"; + const reusableWorkspaceOptions = useMemo( + () => buildReusableExecutionWorkspaceOptionGroups( + dedupeReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []), + ).map((group) => ({ + ...group, + options: group.options.filter((option) => reusableWorkspaceOptionMatches(option, workspaceSearch)), + })).filter((group) => group.options.length > 0), + [reusableExecutionWorkspaces, workspaceSearch], + ); + const boundWorkspace = (reusableExecutionWorkspaces ?? []).find( + (workspace) => workspace.id === issue.executionWorkspaceId, + ) ?? issue.currentExecutionWorkspace ?? null; + const workspaceTriggerLabel = activeWorkspacePickerMode === "isolated" + ? "New isolated workspace" + : activeWorkspacePickerMode === "reuse" + ? boundWorkspace?.name ?? "Reuse existing workspace" + : "Default"; + const workspaceTriggerTitle = activeWorkspacePickerMode === "reuse" + ? boundWorkspace?.branchName ?? undefined + : undefined; + const closeWorkspacePicker = () => { + setWorkspacePickerOpen(false); + setWorkspacePickerStep("mode"); + setWorkspaceSearch(""); + }; + const saveWorkspaceSelection = ( + selection: null | "isolated_workspace" | "reuse_existing", + workspace?: ExecutionWorkspace, + ) => { + const update = buildWorkspaceSelectionUpdate(selection, workspace?.id, workspace?.mode); + if (!update) return; + onUpdate(update); + closeWorkspacePicker(); + }; const issueUsesMainWorkspace = useMemo( () => isMainIssueWorkspace({ issue, project: issueProject }), [issue, issueProject], @@ -2405,8 +2481,124 @@ export function IssueProperties({ - {hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( + {workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( + {workspacePickerEligible ? ( + { + setWorkspacePickerOpen(open); + if (!open) { + setWorkspacePickerStep("mode"); + setWorkspaceSearch(""); + } + }} + triggerContent={( + + {workspaceTriggerLabel} + + )} + triggerClassName="min-w-0 max-w-full" + popoverClassName={cn("max-w-full", inline ? "w-full" : "w-72")} + > + {workspacePickerStep === "mode" ? ( + <> +
+ + + +
+
+ {issue.executionWorkspaceId ? "Current workspace stays active. Applies on the next run." : "Applies on the next run."} +
+ + ) : ( + <> +
+ + setWorkspaceSearch(event.target.value)} + autoFocus={!inline} + aria-label="Search reusable workspaces" + /> +
+
+ {reusableExecutionWorkspacesLoading ? ( +
Loading workspaces...
+ ) : reusableExecutionWorkspacesError ? ( +
Failed to load workspaces.
+ ) : reusableWorkspaceOptions.length === 0 ? ( +
No matching workspaces.
+ ) : reusableWorkspaceOptions.map((group) => ( +
+
{group.label}
+ {group.options.map((option) => ( + + ))} +
+ ))} +
+
+ {issue.executionWorkspaceId ? "Current workspace stays active. Applies on the next run." : "Applies on the next run."} +
+ + )} +
+ ) : null} {showWorkspaceDetailLink && issue.executionWorkspaceId && ( { + it("builds the true project-default update", () => { + expect(buildWorkspaceSelectionUpdate(null, null, null)).toEqual({ + executionWorkspacePreference: null, + executionWorkspaceId: null, + executionWorkspaceSettings: null, + }); + }); + + it("builds a new isolated workspace update", () => { + expect(buildWorkspaceSelectionUpdate("isolated_workspace", null, null)).toEqual({ + executionWorkspacePreference: "isolated_workspace", + executionWorkspaceId: null, + executionWorkspaceSettings: { + mode: "isolated_workspace", + environmentId: null, + }, + }); + }); + + it("builds a reuse-existing update with the reused workspace mode", () => { + expect(buildWorkspaceSelectionUpdate("reuse_existing", "workspace-1", "operator_branch")).toEqual({ + executionWorkspacePreference: "reuse_existing", + executionWorkspaceId: "workspace-1", + executionWorkspaceSettings: { + mode: "operator_branch", + environmentId: null, + }, + }); + }); + + it("does not build a reuse-existing update without a workspace id", () => { + expect(buildWorkspaceSelectionUpdate("reuse_existing", null, "isolated_workspace")).toBeNull(); + }); + + it("keeps the old card's shared-workspace payload available", () => { + expect(buildWorkspaceSelectionUpdate("shared_workspace", null, null)).toEqual({ + executionWorkspacePreference: "shared_workspace", + executionWorkspaceId: null, + executionWorkspaceSettings: { + mode: "shared_workspace", + environmentId: null, + }, + }); + }); + + it("presents a bound isolated workspace as reuse existing", () => { + expect(currentWorkspaceSelection({ + executionWorkspaceId: "workspace-1", + executionWorkspacePreference: "isolated_workspace", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + currentExecutionWorkspace: null, + }, null)).toBe("reuse_existing"); + }); +}); diff --git a/ui/src/lib/issue-workspace-selection.ts b/ui/src/lib/issue-workspace-selection.ts new file mode 100644 index 0000000000..777b08ab63 --- /dev/null +++ b/ui/src/lib/issue-workspace-selection.ts @@ -0,0 +1,82 @@ +import type { ExecutionWorkspaceMode, Issue } from "@paperclipai/shared"; +import { + defaultExecutionWorkspaceModeForProject, + issueExecutionWorkspaceModeForExistingWorkspace, +} from "./project-workspace-defaults"; + +export type IssueWorkspaceSelection = ExecutionWorkspaceMode | "reuse_existing" | null; + +type IssueWorkspaceSelectionSource = Pick< + Issue, + | "executionWorkspaceId" + | "executionWorkspacePreference" + | "executionWorkspaceSettings" + | "currentExecutionWorkspace" +>; + +type ProjectWorkspaceSelectionSource = Parameters[0]; + +export interface WorkspaceSelectionUpdate extends Record { + executionWorkspacePreference: IssueWorkspaceSelection; + executionWorkspaceId: string | null; + executionWorkspaceSettings: { + mode: ExecutionWorkspaceMode; + environmentId: null; + } | null; +} + +/** + * Resolves the issue's effective workspace choice. A bound isolated or operator + * workspace is always presented as reuse-existing, even if its persisted + * preference still describes how it was originally created. + */ +export function currentWorkspaceSelection( + issue: IssueWorkspaceSelectionSource, + project: ProjectWorkspaceSelectionSource, +): IssueWorkspaceSelection { + const persistedMode = + issue.currentExecutionWorkspace?.mode + ?? issue.executionWorkspaceSettings?.mode + ?? issue.executionWorkspacePreference; + + if ( + issue.executionWorkspaceId + && (persistedMode === "isolated_workspace" || persistedMode === "operator_branch") + ) { + return "reuse_existing"; + } + + return ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(project) + ) as IssueWorkspaceSelection; +} + +/** Returns null when the selection is incomplete and cannot be saved. */ +export function buildWorkspaceSelectionUpdate( + selection: IssueWorkspaceSelection, + workspaceId: string | null | undefined, + reusedWorkspaceMode: string | null | undefined, +): WorkspaceSelectionUpdate | null { + if (selection === "reuse_existing" && !workspaceId) return null; + + if (selection === null) { + return { + executionWorkspacePreference: null, + executionWorkspaceId: null, + executionWorkspaceSettings: null, + }; + } + + return { + executionWorkspacePreference: selection, + executionWorkspaceId: selection === "reuse_existing" ? workspaceId! : null, + executionWorkspaceSettings: { + mode: selection === "reuse_existing" + ? issueExecutionWorkspaceModeForExistingWorkspace(reusedWorkspaceMode) + : selection, + environmentId: null, + }, + }; +}