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:
parent
8eaa5caa05
commit
109d81db4f
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue