From 109d81db4fba684edbdba586ec0a3a3dbea6ae9e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:19:09 -0500 Subject: [PATCH] Add task workspace picker to properties pane (#12693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task properties pane lets an operator change the settings for one task. > - The pane did not let an operator select the execution workspace for the next run. > - The existing workspace card had selection rules that another control could copy by mistake. > - This pull request adds one shared selection module and one compact property picker. > - The picker keeps the current workspace active until the next run. > - The benefit is a clear workspace choice in the task properties pane without an API change. ## Linked Issues or Issue Description **Subsystem affected** ui/ — React + Vite board UI **Problem or motivation** An operator cannot set the execution workspace from the new task properties pane. The existing task workspace card also owns selection rules that a second control could copy and change over time. **Proposed solution** Add a compact workspace property picker. Put the shared selection and update rules in one UI module. Show the picker only when isolated workspaces and the project workspace policy are enabled. **Alternatives considered** The existing workspace card could remain the only control. This would leave the new task interface incomplete. The picker could also copy the card logic, but that would create two sources of truth. **Roadmap alignment** This is a small UI improvement for existing workspace controls. It does not duplicate a planned item in `ROADMAP.md`. Related workspace work: Refs #12682. That pull request changes runner recovery and other workspace controls. It does not add this task property picker. ## What Changed - Added shared helpers for the current workspace selection and its issue update payload. - Updated the existing workspace card to use the shared helpers without changing its project-default behavior. - Added a gated workspace property picker with mode and workspace search steps. - Added unit and component tests for visibility, selection payloads, search, and workspace reuse. - Rebased onto the upstream native-run teardown fix so CI drains background heartbeat writes before PostgreSQL cleanup. ## Verification - `NODE_ENV=test pnpm --filter @paperclipai/ui exec vitest run src/lib/issue-workspace-selection.test.ts src/components/IssueProperties.test.tsx src/components/RoutineRunVariablesDialog.test.tsx` — 74 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `NODE_ENV=test pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/native-question-bridge.test.ts` — 8 tests passed on the rebased head. The upstream teardown drain prevents the prior PostgreSQL cleanup deadlock. ## Risks - Low risk. This is a gated UI-only change. - A wrong selection payload could change the next workspace mode. Exact payload tests cover every mode. - The old card must keep its existing project-default payload. A shared-helper test covers that payload. > 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 with GPT-5. The exact serving revision and context window are not exposed. The agent used reasoning, tool use, and code execution. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/IssueProperties.test.tsx | 137 ++++++++++++ ui/src/components/IssueWorkspaceCard.tsx | 54 ++--- .../RoutineRunVariablesDialog.test.tsx | 42 +++- .../components/RoutineRunVariablesDialog.tsx | 8 +- .../issue-properties/IssueProperties.tsx | 196 +++++++++++++++++- ui/src/lib/issue-workspace-selection.test.ts | 61 ++++++ ui/src/lib/issue-workspace-selection.ts | 82 ++++++++ 7 files changed, 534 insertions(+), 46 deletions(-) create mode 100644 ui/src/lib/issue-workspace-selection.test.ts create mode 100644 ui/src/lib/issue-workspace-selection.ts 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, + }, + }; +}