import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "@/lib/router"; import type { Issue, ExecutionWorkspace } from "@paperclipai/shared"; import { useQuery } from "@tanstack/react-query"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { environmentsApi } from "../api/environments"; import { instanceSettingsApi } from "../api/instanceSettings"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; import { defaultExecutionWorkspaceModeForProject, } 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"; import { Check, Copy, FileSearch, FolderOpen, FolderSearch, GitBranch, Pencil, X } from "lucide-react"; import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect"; import { Badge } from "@/components/ui/badge"; /* -------------------------------------------------------------------------- */ /* Utility helpers (mirrored from IssueProperties for self-containment) */ /* -------------------------------------------------------------------------- */ 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; /* -------------------------------------------------------------------------- */ /* Sub-components */ /* -------------------------------------------------------------------------- */ function BreakablePath({ text }: { text: string }) { const parts: React.ReactNode[] = []; const segments = text.split(/(?<=[\/-])/); for (let i = 0; i < segments.length; i++) { if (i > 0) parts.push(); parts.push(segments[i]); } return <>{parts}; } function CopyableInline({ value, label, mono }: { value: string; label?: string; mono?: boolean }) { const [copied, setCopied] = useState(false); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { try { await copyTextToClipboard(value); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } catch { /* noop */ } }, [value]); return ( {label && {label}} ); } function workspaceModeLabel(mode: string | null | undefined) { switch (mode) { case "isolated_workspace": return "Isolated workspace"; case "operator_branch": return "Operator branch"; case "cloud_sandbox": return "Cloud environment"; case "adapter_managed": return "Adapter managed"; default: return "Workspace"; } } function configuredWorkspaceLabel( selection: string | null | undefined, reusableWorkspace: ExecutionWorkspace | null, ) { switch (selection) { case "isolated_workspace": return "New isolated workspace"; case "reuse_existing": return reusableWorkspace?.mode === "isolated_workspace" ? "Existing isolated workspace" : "Reuse existing workspace"; default: return "Project default"; } } function projectWorkspaceDetailLink(input: { projectId: string | null | undefined; projectWorkspaceId: string | null | undefined; }) { if (!input.projectId || !input.projectWorkspaceId) return null; return projectWorkspaceUrl({ id: input.projectId, urlKey: input.projectId }, input.projectWorkspaceId); } function workspaceDetailLink(input: { projectId: string | null | undefined; issueProjectWorkspaceId: string | null | undefined; workspace: ExecutionWorkspace | null | undefined; }) { const linkedProjectWorkspaceId = input.workspace?.projectWorkspaceId ?? input.issueProjectWorkspaceId ?? null; if (input.workspace?.mode === "shared_workspace") { return projectWorkspaceDetailLink({ projectId: input.projectId, projectWorkspaceId: linkedProjectWorkspaceId, }); } return input.workspace ? `/execution-workspaces/${input.workspace.id}` : null; } function statusBadge(status: string) { const colors: Record = { active: "bg-green-500/15 text-green-700 dark:text-green-400", idle: "bg-muted text-muted-foreground", in_review: "bg-blue-500/15 text-blue-700 dark:text-blue-400", archived: "bg-muted text-muted-foreground", }; return ( {status.replace(/_/g, " ")} ); } /* -------------------------------------------------------------------------- */ /* Main component */ /* -------------------------------------------------------------------------- */ interface IssueWorkspaceCardProps { issue: Omit< Pick< Issue, | "companyId" | "projectId" | "projectWorkspaceId" | "executionWorkspaceId" | "executionWorkspacePreference" | "executionWorkspaceSettings" >, "companyId" > & { companyId: string | null; currentExecutionWorkspace?: ExecutionWorkspace | null; }; project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null; environmentId?: string | null; } | null; workspaces?: Array<{ id: string; isPrimary: boolean }>; } | null; onUpdate: (data: Record) => void; initialEditing?: boolean; livePreview?: boolean; onDraftChange?: (data: Record | 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. */ onOpenFileByPath?: () => void; } export function IssueWorkspaceCard({ issue, project, onUpdate, initialEditing = false, livePreview = false, onDraftChange, onBrowseFiles, onOpenFileByPath, }: IssueWorkspaceCardProps) { const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); const companyId = issue.companyId ?? selectedCompanyId; const [editing, setEditing] = useState(initialEditing); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); const environmentsEnabled = experimentalSettings?.enableEnvironments === true; // Managed-sandbox-only policy: the workspace path is a host filesystem path, // so the card omits it and keeps branch, repo, and environment. The gate fails // closed whenever the policy is unknown — in flight and also on a failed read // — because an unresolved policy reads as "not managed" and would show the // path the policy exists to hide. const hideHostPaths = experimentalSettings === undefined || experimentalSettings.enableManagedSandboxOnly === true; const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true && Boolean(project?.executionWorkspacePolicy?.enabled); const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; const { data: environments } = useQuery({ queryKey: queryKeys.environments.list(companyId!), queryFn: () => environmentsApi.list(companyId!), enabled: Boolean(companyId) && environmentsEnabled, }); 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) && editing && workspaceIsolationControlsVisible, }); const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? []; const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId) ?? workspace ?? null; const currentSelection = currentWorkspaceSelection(issue, project) ?? defaultExecutionWorkspaceModeForProject(project); const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); const projectEnvironmentId = environmentsEnabled ? project?.executionWorkspacePolicy?.environmentId ?? null : null; const currentReusableEnvironmentId = selectedReusableExecutionWorkspace?.config?.environmentId ?? null; const currentEnvironmentId = environmentsEnabled ? ( (currentSelection === "reuse_existing" && currentReusableEnvironmentId) ?? workspace?.config?.environmentId ?? projectEnvironmentId ) : null; const currentEnvironment = environments?.find((environment) => environment.id === currentEnvironmentId) ?? null; useEffect(() => { if (editing) return; setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); }, [currentSelection, editing, issue.executionWorkspaceId]); const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace"); const configuredReusableWorkspace = selectableReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId) ?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null); const selectedReusableWorkspaceLink = workspaceDetailLink({ projectId: project?.id, issueProjectWorkspaceId: issue.projectWorkspaceId, workspace: selectedReusableExecutionWorkspace, }); const currentWorkspaceLink = workspaceDetailLink({ projectId: project?.id, issueProjectWorkspaceId: issue.projectWorkspaceId, workspace, }); const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; const draftWorkspaceBranchName = draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace" ? configuredReusableWorkspace?.branchName ?? null : null; const buildWorkspaceDraftUpdate = useCallback(() => buildWorkspaceSelectionUpdate( draftSelection, draftExecutionWorkspaceId || null, configuredReusableWorkspace?.mode, ), [ configuredReusableWorkspace?.mode, draftExecutionWorkspaceId, draftSelection, ]); useEffect(() => { if (!onDraftChange || !workspaceIsolationControlsVisible) return; onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig, workspaceBranchName: draftWorkspaceBranchName, }); }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange, workspaceIsolationControlsVisible]); const handleSave = useCallback(() => { if (!canSaveWorkspaceConfig || !workspaceIsolationControlsVisible) return; const update = buildWorkspaceDraftUpdate(); if (!update) return; onUpdate(update); setEditing(false); }, [ buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, workspaceIsolationControlsVisible, onUpdate, ]); const handleCancel = useCallback(() => { setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); setEditing(false); }, [currentSelection, issue.executionWorkspaceId]); if (!policyEnabled || !project) return null; const showEditingControls = workspaceIsolationControlsVisible && (livePreview || editing); return (
{/* Header row */}
{activeNonDefaultWorkspace && workspace ? workspaceModeLabel(workspace.mode) : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} {workspace ? statusBadge(workspace.status) : statusBadge("idle")}
{workspaceIsolationControlsVisible && (
{showEditingControls ? ( <> ) : ( )}
)}
{/* Read-only info */} {!showEditingControls && (
{workspace?.branchName && (
)} {workspace?.cwd && !hideHostPaths && (
)} {workspace?.repoUrl && (
Repo:
)} {environmentsEnabled && currentEnvironmentId && (
Environment: {currentEnvironment?.name ?? currentEnvironmentId} {currentSelection === "reuse_existing" && currentReusableEnvironmentId === currentEnvironmentId ? " · reused workspace" : !issue.executionWorkspaceSettings?.environmentId && projectEnvironmentId === currentEnvironmentId ? " · project default" : null}
)} {!workspace && (
{currentSelection === "isolated_workspace" ? "A fresh isolated workspace will be created when this task runs." : currentSelection === "reuse_existing" ? "This task will reuse an existing workspace when it runs." : "This task will use the project default workspace configuration when it runs."}
)} {currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
Reusing:{" "} {selectedReusableWorkspaceLink ? ( ) : ( )}
)} {workspace && currentWorkspaceLink && (
View workspace details →
)}
)} {/* Editing controls */} {editing && workspaceIsolationControlsVisible && (
{draftSelection === "reuse_existing" && ( setDraftExecutionWorkspaceId(workspaceId)} loading={reusableExecutionWorkspacesLoading} error={reusableExecutionWorkspacesError} /> )} {/* Current workspace summary when editing */} {workspace && (
Current:{" "} {currentWorkspaceLink ? ( ) : ( )} {" · "} {workspace.status}
)}
)} {/* Workspace file discovery — calm row under the workspace identity. */} {!showEditingControls && onBrowseFiles && (
)}
); }