Add project-first execution workspace policies
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { AdapterConfigFieldsProps } from "./types";
|
||||
import { DraftInput, Field, help } from "../components/agent-config-primitives";
|
||||
import { CollapsibleSection, DraftInput, Field, help } from "../components/agent-config-primitives";
|
||||
import { RuntimeServicesJsonField } from "./runtime-json-fields";
|
||||
|
||||
const inputClass =
|
||||
@@ -48,6 +49,7 @@ export function LocalWorkspaceRuntimeFields({
|
||||
config,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const existing = readWorkspaceStrategy(config);
|
||||
const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type;
|
||||
const updateEditWorkspaceStrategy = (patch: Partial<typeof existing>) => {
|
||||
@@ -62,75 +64,81 @@ export function LocalWorkspaceRuntimeFields({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={strategyType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (isCreate) {
|
||||
set!({ workspaceStrategyType: nextType });
|
||||
} else {
|
||||
updateEditWorkspaceStrategy({ type: nextType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="project_primary">Project primary workspace</option>
|
||||
<option value="git_worktree">Git worktree</option>
|
||||
</select>
|
||||
</Field>
|
||||
<CollapsibleSection
|
||||
title="Advanced Workspace Overrides"
|
||||
open={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
>
|
||||
<div className="space-y-3 pt-1">
|
||||
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={strategyType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (isCreate) {
|
||||
set!({ workspaceStrategyType: nextType });
|
||||
} else {
|
||||
updateEditWorkspaceStrategy({ type: nextType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="project_primary">Project primary workspace</option>
|
||||
<option value="git_worktree">Git worktree</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{strategyType === "git_worktree" && (
|
||||
<>
|
||||
<Field label="Base ref" hint={help.workspaceBaseRef}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ workspaceBaseRef: v })
|
||||
: updateEditWorkspaceStrategy({ baseRef: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ workspaceBranchTemplate: v })
|
||||
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="{{issue.identifier}}-{{slug}}"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ worktreeParentDir: v })
|
||||
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder=".paperclip/worktrees"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<RuntimeServicesJsonField
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
/>
|
||||
</>
|
||||
{strategyType === "git_worktree" && (
|
||||
<>
|
||||
<Field label="Base ref" hint={help.workspaceBaseRef}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ workspaceBaseRef: v })
|
||||
: updateEditWorkspaceStrategy({ baseRef: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ workspaceBranchTemplate: v })
|
||||
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="{{issue.identifier}}-{{slug}}"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
|
||||
<DraftInput
|
||||
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ worktreeParentDir: v })
|
||||
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder=".paperclip/worktrees"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<RuntimeServicesJsonField
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
const project = orderedProjects.find((p) => p.id === id);
|
||||
return project?.name ?? id.slice(0, 8);
|
||||
};
|
||||
const currentProject = issue.projectId
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
|
||||
? true
|
||||
: issue.executionWorkspaceSettings?.mode === "project_primary"
|
||||
? false
|
||||
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
@@ -402,7 +412,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||
onClick={() => {
|
||||
onUpdate({ projectId: null, executionWorkspaceSettings: null });
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
@@ -419,7 +432,15 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
p.id === issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
projectId: p.id,
|
||||
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
|
||||
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
|
||||
: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
@@ -504,6 +525,42 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<PropertyRow label="Workspace">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm">
|
||||
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Toggle whether this issue runs in its own execution workspace.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
executionWorkspaceSettings: {
|
||||
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
||||
@@ -65,7 +65,7 @@ interface IssueDraft {
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
@@ -99,7 +99,6 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
chrome: boolean;
|
||||
useProjectWorkspace: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
||||
@@ -127,9 +126,6 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
overrides.adapterConfig = adapterConfig;
|
||||
}
|
||||
if (!input.useProjectWorkspace) {
|
||||
overrides.useProjectWorkspace = false;
|
||||
}
|
||||
return Object.keys(overrides).length > 0 ? overrides : null;
|
||||
}
|
||||
|
||||
@@ -180,10 +176,11 @@ export function NewIssueDialog() {
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||
|
||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||
@@ -300,7 +297,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
useIsolatedExecutionWorkspace,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
@@ -312,7 +309,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
useIsolatedExecutionWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
]);
|
||||
@@ -321,6 +318,7 @@ export function NewIssueDialog() {
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
setDialogCompanyId(selectedCompanyId);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (newIssueDefaults.title) {
|
||||
@@ -333,7 +331,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
} else if (draft && draft.title.trim()) {
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
@@ -344,7 +342,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
@@ -353,7 +351,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
@@ -363,7 +361,6 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,10 +393,11 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
@@ -410,7 +408,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -426,8 +424,14 @@ export function NewIssueDialog() {
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
chrome: assigneeChrome,
|
||||
useProjectWorkspace: assigneeUseProjectWorkspace,
|
||||
});
|
||||
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy;
|
||||
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
||||
? {
|
||||
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
|
||||
}
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
title: title.trim(),
|
||||
@@ -437,6 +441,7 @@ export function NewIssueDialog() {
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,6 +472,8 @@ export function NewIssueDialog() {
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const assigneeOptionsTitle =
|
||||
assigneeAdapterType === "claude_local"
|
||||
? "Claude options"
|
||||
@@ -503,6 +510,26 @@ export function NewIssueDialog() {
|
||||
})),
|
||||
[orderedProjects],
|
||||
);
|
||||
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
||||
const policy = nextProject?.executionWorkspacePolicy;
|
||||
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
|
||||
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
|
||||
}, [orderedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen || !projectId || executionWorkspaceDefaultProjectId.current === projectId) {
|
||||
return;
|
||||
}
|
||||
const project = orderedProjects.find((entry) => entry.id === projectId);
|
||||
if (!project) return;
|
||||
executionWorkspaceDefaultProjectId.current = projectId;
|
||||
setUseIsolatedExecutionWorkspace(
|
||||
Boolean(project.executionWorkspacePolicy?.enabled && project.executionWorkspacePolicy.defaultMode === "isolated"),
|
||||
);
|
||||
}, [newIssueOpen, orderedProjects, projectId]);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() => {
|
||||
return [...(assigneeAdapterModels ?? [])]
|
||||
@@ -705,7 +732,7 @@ export function NewIssueDialog() {
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
onChange={setProjectId}
|
||||
onChange={handleProjectChange}
|
||||
onConfirm={() => {
|
||||
descriptionEditorRef.current?.focus();
|
||||
}}
|
||||
@@ -740,6 +767,34 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium">Use isolated issue checkout</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Create an issue-specific execution workspace instead of using the project's primary checkout.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<button
|
||||
@@ -800,23 +855,6 @@ export function NewIssueDialog() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Use project workspace</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { CollapsibleSection, DraftInput } from "./agent-config-primitives";
|
||||
|
||||
const PROJECT_STATUSES = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
@@ -80,6 +81,7 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
|
||||
const [workspaceCwd, setWorkspaceCwd] = useState("");
|
||||
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
|
||||
@@ -106,6 +108,16 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
|
||||
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
|
||||
const workspaces = project.workspaces ?? [];
|
||||
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
|
||||
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
|
||||
const executionWorkspaceDefaultMode =
|
||||
executionWorkspacePolicy?.defaultMode === "isolated" ? "isolated" : "project_primary";
|
||||
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
||||
type: "git_worktree",
|
||||
baseRef: "",
|
||||
branchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
};
|
||||
|
||||
const invalidateProject = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
@@ -146,6 +158,19 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
setGoalOpen(false);
|
||||
};
|
||||
|
||||
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({
|
||||
executionWorkspacePolicy: {
|
||||
enabled: executionWorkspacesEnabled,
|
||||
defaultMode: executionWorkspaceDefaultMode,
|
||||
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
|
||||
...executionWorkspacePolicy,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
|
||||
const isGitHubRepoUrl = (value: string) => {
|
||||
@@ -346,6 +371,162 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Execution Workspaces</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Execution workspaces help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">Enable isolated issue checkouts</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Let issues choose between the project’s primary checkout and an isolated execution workspace.
|
||||
</div>
|
||||
</div>
|
||||
{onUpdate ? (
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{executionWorkspacesEnabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm">New issues default to isolated checkout</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
If disabled, new issues stay on the project’s primary checkout unless someone opts in.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
executionWorkspaceDefaultMode === "isolated" ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateExecutionWorkspacePolicy({
|
||||
defaultMode: executionWorkspaceDefaultMode === "isolated" ? "project_primary" : "isolated",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
executionWorkspaceDefaultMode === "isolated" ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Advanced Checkout Settings"
|
||||
open={executionWorkspaceAdvancedOpen}
|
||||
onToggle={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
|
||||
>
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="rounded-md border border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Base ref</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.baseRef ?? ""}
|
||||
onCommit={(value) =>
|
||||
updateExecutionWorkspacePolicy({
|
||||
workspaceStrategy: {
|
||||
...executionWorkspaceStrategy,
|
||||
type: "git_worktree",
|
||||
baseRef: value || null,
|
||||
},
|
||||
})}
|
||||
immediate
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Branch template</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.branchTemplate ?? ""}
|
||||
onCommit={(value) =>
|
||||
updateExecutionWorkspacePolicy({
|
||||
workspaceStrategy: {
|
||||
...executionWorkspaceStrategy,
|
||||
type: "git_worktree",
|
||||
branchTemplate: value || null,
|
||||
},
|
||||
})}
|
||||
immediate
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||
placeholder="{{issue.identifier}}-{{slug}}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Worktree parent dir</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
|
||||
onCommit={(value) =>
|
||||
updateExecutionWorkspacePolicy({
|
||||
workspaceStrategy: {
|
||||
...executionWorkspaceStrategy,
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: value || null,
|
||||
},
|
||||
})}
|
||||
immediate
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||
placeholder=".paperclip/worktrees"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runtime services stay under Paperclip control and are not configured here yet.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Workspaces</span>
|
||||
|
||||
Reference in New Issue
Block a user