Add task workspace picker to properties pane (#12693)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-02 08:19:09 -05:00 committed by GitHub
parent 8eaa5caa05
commit 109d81db4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 534 additions and 46 deletions

View File

@ -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());
});
});

View File

@ -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<string, unknown>) => void;
initialEditing?: boolean;
livePreview?: boolean;
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
onDraftChange?: (data: Record<string, unknown> | 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("");

View File

@ -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<string, unknown> | null = {
executionWorkspaceId: null as string | null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
let issueWorkspaceBranchName: string | null = null;
let issueWorkspaceCanSave = true;
let latestWorkspaceIssue: Record<string, unknown> | null = null;
vi.mock("../api/instanceSettings", () => ({
@ -32,7 +33,7 @@ vi.mock("./IssueWorkspaceCard", async () => {
}: {
issue: Record<string, unknown>;
onDraftChange?: (
data: Record<string, unknown>,
data: Record<string, unknown> | 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(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
});
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({

View File

@ -323,15 +323,17 @@ export function RoutineRunVariablesDialog({
}, []);
const handleWorkspaceDraftChange = useCallback((
data: Record<string, unknown>,
data: Record<string, unknown> | 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;
});

View File

@ -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({
</PropertyPicker>
</PropertySection>
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
{workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<PropertySection title="Workspace">
{workspacePickerEligible ? (
<PropertyPicker
inline={inline}
label="Execution"
open={workspacePickerOpen}
onOpenChange={(open) => {
setWorkspacePickerOpen(open);
if (!open) {
setWorkspacePickerStep("mode");
setWorkspaceSearch("");
}
}}
triggerContent={(
<span className="truncate" title={workspaceTriggerTitle}>
{workspaceTriggerLabel}
</span>
)}
triggerClassName="min-w-0 max-w-full"
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-72")}
>
{workspacePickerStep === "mode" ? (
<>
<div className="space-y-0.5">
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent/50"
onClick={() => saveWorkspaceSelection(null)}
>
<span className="min-w-0 flex-1">
<span className="block text-sm">Default</span>
<span className="block text-xs text-muted-foreground">Use the project workspace policy</span>
</span>
{activeWorkspacePickerMode === "default" ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent/50"
onClick={() => saveWorkspaceSelection("isolated_workspace")}
>
<span className="min-w-0 flex-1">
<span className="block text-sm">New isolated workspace</span>
<span className="block text-xs text-muted-foreground">Create a fresh workspace on the next run</span>
</span>
{activeWorkspacePickerMode === "isolated" ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent/50"
onClick={() => setWorkspacePickerStep("reuse")}
>
<span className="min-w-0 flex-1">
<span className="block text-sm">Reuse existing workspace</span>
<span className="block text-xs text-muted-foreground">Pick a workspace to reuse</span>
</span>
{activeWorkspacePickerMode === "reuse" ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</div>
<div className="mt-1 border-t border-border px-2 py-1.5 text-xs text-muted-foreground">
{issue.executionWorkspaceId ? "Current workspace stays active. Applies on the next run." : "Applies on the next run."}
</div>
</>
) : (
<>
<div className="border-b border-border pb-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded px-1 py-1 text-xs text-muted-foreground hover:bg-accent/50 hover:text-foreground"
onClick={() => setWorkspacePickerStep("mode")}
aria-label="Back to workspace options"
>
<ChevronLeft className="h-3.5 w-3.5" />
Workspace mode
</button>
<input
className="block w-full bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search workspaces..."
value={workspaceSearch}
onChange={(event) => setWorkspaceSearch(event.target.value)}
autoFocus={!inline}
aria-label="Search reusable workspaces"
/>
</div>
<div className="max-h-48 overflow-y-auto overscroll-contain py-1">
{reusableExecutionWorkspacesLoading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">Loading workspaces...</div>
) : reusableExecutionWorkspacesError ? (
<div className="px-2 py-2 text-xs text-destructive">Failed to load workspaces.</div>
) : reusableWorkspaceOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matching workspaces.</div>
) : reusableWorkspaceOptions.map((group) => (
<div key={group.id} className="py-1">
<div className="px-2 pb-1 text-xs font-medium text-muted-foreground">{group.label}</div>
{group.options.map((option) => (
<button
key={option.key}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent/50"
onClick={() => saveWorkspaceSelection("reuse_existing", option.workspace)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{option.label}</span>
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
</span>
{issue.executionWorkspaceId === option.workspaceId ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
))}
</div>
))}
</div>
<div className="border-t border-border px-2 py-1.5 text-xs text-muted-foreground">
{issue.executionWorkspaceId ? "Current workspace stays active. Applies on the next run." : "Applies on the next run."}
</div>
</>
)}
</PropertyPicker>
) : null}
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceSelectionUpdate,
currentWorkspaceSelection,
} from "./issue-workspace-selection";
describe("issue workspace selection", () => {
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");
});
});

View File

@ -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<typeof defaultExecutionWorkspaceModeForProject>[0];
export interface WorkspaceSelectionUpdate extends Record<string, unknown> {
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,
},
};
}