Refine project and agent configuration UI
This commit is contained in:
@@ -13,9 +13,10 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { CollapsibleSection, DraftInput } from "./agent-config-primitives";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
|
||||
const PROJECT_STATUSES = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
@@ -28,15 +29,84 @@ const PROJECT_STATUSES = [
|
||||
interface ProjectPropertiesProps {
|
||||
project: Project;
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
|
||||
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
|
||||
}
|
||||
|
||||
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
|
||||
export type ProjectConfigFieldKey =
|
||||
| "name"
|
||||
| "description"
|
||||
| "status"
|
||||
| "goals"
|
||||
| "execution_workspace_enabled"
|
||||
| "execution_workspace_default_mode"
|
||||
| "execution_workspace_base_ref"
|
||||
| "execution_workspace_branch_template"
|
||||
| "execution_workspace_worktree_parent_dir";
|
||||
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function SaveIndicator({ state }: { state: ProjectFieldSaveState }) {
|
||||
if (state === "saving") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Saving
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === "saved") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-green-600 dark:text-green-400">
|
||||
<Check className="h-3 w-3" />
|
||||
Saved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === "error") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
label,
|
||||
state,
|
||||
}: {
|
||||
label: string;
|
||||
state: ProjectFieldSaveState;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<SaveIndicator state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyRow({
|
||||
label,
|
||||
children,
|
||||
alignStart = false,
|
||||
valueClassName = "",
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
alignStart?: boolean;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex gap-3 py-1.5", alignStart ? "items-start" : "items-center")}>
|
||||
<div className="shrink-0 w-20">{label}</div>
|
||||
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5", valueClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +147,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) {
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
@@ -87,6 +157,15 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
|
||||
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
|
||||
if (onFieldUpdate) {
|
||||
onFieldUpdate(field, data);
|
||||
return;
|
||||
}
|
||||
onUpdate?.(data);
|
||||
};
|
||||
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
|
||||
|
||||
const { data: allGoals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
@@ -148,19 +227,19 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
});
|
||||
|
||||
const removeGoal = (goalId: string) => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
if (!onUpdate && !onFieldUpdate) return;
|
||||
commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
};
|
||||
|
||||
const addGoal = (goalId: string) => {
|
||||
if (!onUpdate || linkedGoalIds.includes(goalId)) return;
|
||||
onUpdate({ goalIds: [...linkedGoalIds, goalId] });
|
||||
if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
|
||||
commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
|
||||
setGoalOpen(false);
|
||||
};
|
||||
|
||||
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({
|
||||
if (!onUpdate && !onFieldUpdate) return;
|
||||
return {
|
||||
executionWorkspacePolicy: {
|
||||
enabled: executionWorkspacesEnabled,
|
||||
defaultMode: executionWorkspaceDefaultMode,
|
||||
@@ -168,7 +247,7 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
...executionWorkspacePolicy,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
@@ -279,13 +358,46 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
{onUpdate ? (
|
||||
<div>
|
||||
<div className="space-y-1 pb-4">
|
||||
<PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
<DraftInput
|
||||
value={project.name}
|
||||
onCommit={(name) => commitField("name", { name })}
|
||||
immediate
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none"
|
||||
placeholder="Project name"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm">{project.name}</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow
|
||||
label={<FieldLabel label="Description" state={fieldState("description")} />}
|
||||
alignStart
|
||||
valueClassName="space-y-0.5"
|
||||
>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
<InlineEditor
|
||||
value={project.description ?? ""}
|
||||
onSave={(description) => commitField("description", { description })}
|
||||
as="p"
|
||||
className="text-sm text-muted-foreground"
|
||||
placeholder="Add a description..."
|
||||
multiline
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.description?.trim() || "No description"}
|
||||
</p>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={<FieldLabel label="Status" state={fieldState("status")} />}>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
<ProjectStatusPicker
|
||||
status={project.status}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
onChange={(status) => commitField("status", { status })}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={project.status} />
|
||||
@@ -296,238 +408,87 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<div className="py-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">Goals</span>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{linkedGoals.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-1.5 max-w-[220px]">
|
||||
{linkedGoals.map((goal) => (
|
||||
<span
|
||||
key={goal.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
<PropertyRow
|
||||
label={<FieldLabel label="Goals" state={fieldState("goals")} />}
|
||||
alignStart
|
||||
valueClassName="space-y-2"
|
||||
>
|
||||
{linkedGoals.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{linkedGoals.map((goal) => (
|
||||
<span
|
||||
key={goal.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[220px] truncate">
|
||||
{goal.title}
|
||||
</Link>
|
||||
{(onUpdate || onFieldUpdate) && (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
onClick={() => removeGoal(goal.id)}
|
||||
aria-label={`Remove goal ${goal.title}`}
|
||||
>
|
||||
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[140px] truncate">
|
||||
{goal.title}
|
||||
</Link>
|
||||
{onUpdate && (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
onClick={() => removeGoal(goal.id)}
|
||||
aria-label={`Remove goal ${goal.title}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{onUpdate && (
|
||||
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 px-2"
|
||||
disabled={availableGoals.length === 0}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Goal
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="end">
|
||||
{availableGoals.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
All goals linked.
|
||||
</div>
|
||||
) : (
|
||||
availableGoals.map((goal) => (
|
||||
<button
|
||||
key={goal.id}
|
||||
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => addGoal(goal.id)}
|
||||
>
|
||||
{goal.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(onUpdate || onFieldUpdate) && (
|
||||
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 w-fit px-2"
|
||||
disabled={availableGoals.length === 0}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Goal
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{availableGoals.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
All goals linked.
|
||||
</div>
|
||||
) : (
|
||||
availableGoals.map((goal) => (
|
||||
<button
|
||||
key={goal.id}
|
||||
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => addGoal(goal.id)}
|
||||
>
|
||||
{goal.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
|
||||
<span className="text-sm">{formatDate(project.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
|
||||
<span className="text-sm">{formatDate(project.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
{project.targetDate && (
|
||||
<PropertyRow label="Target Date">
|
||||
<PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
|
||||
<span className="text-sm">{formatDate(project.targetDate)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="my-4" />
|
||||
|
||||
<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="space-y-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Workspaces</span>
|
||||
<Tooltip>
|
||||
@@ -744,14 +705,196 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="my-4" />
|
||||
|
||||
<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="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>Enable isolated issue checkouts</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
|
||||
</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 || onFieldUpdate ? (
|
||||
<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={() =>
|
||||
commitField(
|
||||
"execution_workspace_enabled",
|
||||
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">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span>New issues default to isolated checkout</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
|
||||
</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={() =>
|
||||
commitField(
|
||||
"execution_workspace_default_mode",
|
||||
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>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
|
||||
>
|
||||
{executionWorkspaceAdvancedOpen ? "Hide advanced checkout settings" : "Show advanced checkout settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{executionWorkspaceAdvancedOpen && (
|
||||
<div className="space-y-3">
|
||||
<div className="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="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Base ref</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
|
||||
</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.baseRef ?? ""}
|
||||
onCommit={(value) =>
|
||||
commitField("execution_workspace_base_ref", {
|
||||
...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="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Branch template</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
|
||||
</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.branchTemplate ?? ""}
|
||||
onCommit={(value) =>
|
||||
commitField("execution_workspace_branch_template", {
|
||||
...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="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Worktree parent dir</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
|
||||
</label>
|
||||
</div>
|
||||
<DraftInput
|
||||
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
|
||||
onCommit={(value) =>
|
||||
commitField("execution_workspace_worktree_parent_dir", {
|
||||
...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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(project.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{formatDate(project.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user