Files
paperclip/ui/src/components/IssueProperties.tsx
T
dotta 5a1e17f27f Fix issue workspace reuse after isolation
Persist realized isolated/operator workspaces back onto the issue as reusable workspaces so later runs stay on the same workspace, and update the issue workspace picker to present realized isolated workspaces as existing workspaces.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00

821 lines
31 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
const EXECUTION_WORKSPACE_OPTIONS = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
} | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
function issueModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
return "shared_workspace";
}
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
const persistedMode =
issue.currentExecutionWorkspace?.mode
?? issue.executionWorkspaceSettings?.mode
?? issue.executionWorkspacePreference;
return Boolean(
issue.executionWorkspaceId &&
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
);
}
interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => void;
inline?: boolean;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
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 flex-1">{children}</div>
</div>
);
}
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
function PropertyPicker({
inline,
label,
open,
onOpenChange,
triggerContent,
triggerClassName,
popoverClassName,
popoverAlign = "end",
extra,
children,
}: {
inline?: boolean;
label: string;
open: boolean;
onOpenChange: (open: boolean) => void;
triggerContent: React.ReactNode;
triggerClassName?: string;
popoverClassName?: string;
popoverAlign?: "start" | "center" | "end";
extra?: React.ReactNode;
children: React.ReactNode;
}) {
const btnCn = cn(
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
triggerClassName,
);
if (inline) {
return (
<div>
<PropertyRow label={label}>
<button className={btnCn} onClick={() => onOpenChange(!open)}>
{triggerContent}
</button>
{extra}
</PropertyRow>
{open && (
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
{children}
</div>
)}
</div>
);
}
return (
<PropertyRow label={label}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className={btnCn}>{triggerContent}</button>
</PopoverTrigger>
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
{children}
</PopoverContent>
</Popover>
{extra}
</PropertyRow>
);
}
/** Splits a string at `/` and `-` boundaries, inserting <wbr> for natural line breaks. */
function BreakablePath({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
// Split on path separators and hyphens, keeping them in the output
const segments = text.split(/(?<=[\/-])/);
for (let i = 0; i < segments.length; i++) {
if (i > 0) parts.push(<wbr key={i} />);
parts.push(segments[i]);
}
return <>{parts}</>;
}
/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */
function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
return (
<div className={cn("flex items-start gap-1 group", className)}>
<span className="min-w-0" style={{ overflowWrap: "anywhere" }}>
{label && <span className="text-muted-foreground">{label} </span>}
<span className={mono ? "font-mono" : undefined}><BreakablePath text={value} /></span>
</span>
<button
type="button"
className="shrink-0 mt-0.5 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
</div>
);
}
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false);
const [labelSearch, setLabelSearch] = useState("");
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId;
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId!),
queryFn: () => agentsApi.list(companyId!),
enabled: !!companyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId!),
queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId,
});
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
[projects, issue.projectId],
);
const { orderedProjects } = useProjectOrder({
projects: activeProjects,
companyId,
userId: currentUserId,
});
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(companyId!),
queryFn: () => issuesApi.listLabels(companyId!),
enabled: !!companyId,
});
const createLabel = useMutation({
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
onSuccess: async (created) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
setNewLabelName("");
},
});
const deleteLabel = useMutation({
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
},
});
const toggleLabel = (labelId: string) => {
const ids = issue.labelIds ?? [];
const next = ids.includes(labelId)
? ids.filter((id) => id !== labelId)
: [...ids, labelId];
onUpdate({ labelIds: next });
};
const agentName = (id: string | null) => {
if (!id || !agents) return null;
const agent = agents.find((a) => a.id === id);
return agent?.name ?? id.slice(0, 8);
};
const projectName = (id: string | null) => {
if (!id) return id?.slice(0, 8) ?? "None";
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 =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const { data: reusableExecutionWorkspaces } = 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),
});
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace =
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
?? issue.currentExecutionWorkspace
?? null;
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
? "reuse_existing"
: (
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(currentProject)
);
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
return project ? projectUrl(project) : `/projects/${id}`;
};
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
const sortedAgents = useMemo(
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
[agents, recentAssigneeIds],
);
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
style={{
borderColor: label.color,
backgroundColor: `${label.color}22`,
color: label.color,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</div>
) : (
<>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No labels</span>
</>
);
const labelsContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search labels..."
value={labelSearch}
onChange={(e) => setLabelSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
{(labels ?? [])
.filter((label) => {
if (!labelSearch.trim()) return true;
return label.name.toLowerCase().includes(labelSearch.toLowerCase());
})
.map((label) => {
const selected = (issue.labelIds ?? []).includes(label.id);
return (
<div key={label.id} className="flex items-center gap-1">
<button
className={cn(
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
selected && "bg-accent"
)}
onClick={() => toggleLabel(label.id)}
>
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
<span className="truncate">{label.name}</span>
</button>
<button
type="button"
className="p-1 text-muted-foreground hover:text-destructive rounded"
onClick={() => deleteLabel.mutate(label.id)}
title={`Delete ${label.name}`}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<div className="mt-2 border-t border-border pt-2 space-y-1">
<div className="flex items-center gap-1">
<input
className="h-7 w-7 p-0 rounded bg-transparent"
type="color"
value={newLabelColor}
onChange={(e) => setNewLabelColor(e.target.value)}
/>
<input
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none rounded placeholder:text-muted-foreground/50"
placeholder="New label"
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
/>
</div>
<button
className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs rounded border border-border hover:bg-accent/50 disabled:opacity-50"
disabled={!newLabelName.trim() || createLabel.isPending}
onClick={() =>
createLabel.mutate({
name: newLabelName.trim(),
color: newLabelColor,
})
}
>
<Plus className="h-3 w-3" />
{createLabel.isPending ? "Creating…" : "Create label"}
</button>
</div>
</>
);
const assigneeTrigger = assignee ? (
<Identity name={assignee.name} size="sm" />
) : assigneeUserLabel ? (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{assigneeUserLabel}</span>
</>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Unassigned</span>
</>
);
const assigneeContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
</div>
</>
);
const projectTrigger = issue.projectId ? (
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
/>
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
</>
) : (
<>
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No project</span>
</>
);
const projectContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"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,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
No project
</button>
{orderedProjects
.filter((p) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();
return p.name.toLowerCase().includes(q);
})
.map((p) => (
<button
key={p.id}
className={cn(
"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={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
{p.name}
</button>
))}
</div>
</>
);
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
onChange={(status) => onUpdate({ status })}
showLabel
/>
</PropertyRow>
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
showLabel
/>
</PropertyRow>
<PropertyPicker
inline={inline}
label="Labels"
open={labelsOpen}
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
triggerContent={labelsTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-64"
>
{labelsContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Assignee"
open={assigneeOpen}
onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}
triggerContent={assigneeTrigger}
popoverClassName="w-52"
extra={issue.assigneeAgentId ? (
<Link
to={`/agents/${issue.assigneeAgentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
>
{assigneeContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Project"
open={projectOpen}
onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}
triggerContent={projectTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-fit min-w-[11rem]"
extra={issue.projectId ? (
<Link
to={projectLink(issue.projectId)!}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
>
{projectContent}
</PropertyPicker>
{currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace">
<div className="w-full space-y-2">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={currentExecutionWorkspaceSelection}
onChange={(e) => {
const nextMode = e.target.value;
onUpdate({
executionWorkspacePreference: nextMode,
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
executionWorkspaceSettings: {
mode:
nextMode === "reuse_existing"
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: nextMode,
},
});
}}
>
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: option.label}
</option>
))}
</select>
{currentExecutionWorkspaceSelection === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={issue.executionWorkspaceId ?? ""}
onChange={(e) => {
const nextExecutionWorkspaceId = e.target.value || null;
const nextExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === nextExecutionWorkspaceId,
);
onUpdate({
executionWorkspacePreference: "reuse_existing",
executionWorkspaceId: nextExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
},
});
}}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
</option>
))}
</select>
)}
{issue.currentExecutionWorkspace && (
<div className="text-[11px] text-muted-foreground space-y-0.5">
<div style={{ overflowWrap: "anywhere" }}>
Current:{" "}
<Link
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={issue.currentExecutionWorkspace.name} />
</Link>
{" · "}
{issue.currentExecutionWorkspace.status}
</div>
{issue.currentExecutionWorkspace.cwd && (
<CopyableValue value={issue.currentExecutionWorkspace.cwd} mono className="text-[11px]" />
)}
{issue.currentExecutionWorkspace.branchName && (
<CopyableValue value={issue.currentExecutionWorkspace.branchName} label="Branch:" className="text-[11px]" />
)}
{issue.currentExecutionWorkspace.repoUrl && (
<CopyableValue value={issue.currentExecutionWorkspace.repoUrl} label="Repo:" mono className="text-[11px]" />
)}
</div>
)}
{!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && (
<CopyableValue value={currentProject.primaryWorkspace.cwd} mono className="text-[11px] text-muted-foreground" />
)}
</div>
</PropertyRow>
)}
{issue.parentId && (
<PropertyRow label="Parent">
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
className="text-sm hover:underline"
>
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
</Link>
</PropertyRow>
)}
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
{(issue.createdByAgentId || issue.createdByUserId) && (
<PropertyRow label="Created by">
{issue.createdByAgentId ? (
<Link
to={`/agents/${issue.createdByAgentId}`}
className="hover:underline"
>
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
</Link>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
</>
)}
</PropertyRow>
)}
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDate(issue.startedAt)}</span>
</PropertyRow>
)}
{issue.completedAt && (
<PropertyRow label="Completed">
<span className="text-sm">{formatDate(issue.completedAt)}</span>
</PropertyRow>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDate(issue.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
</div>
</div>
);
}