Spaces:
Paused
Paused
| 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 { instanceSettingsApi } from "../api/instanceSettings"; | |
| import { useCompany } from "../context/CompanyContext"; | |
| import { queryKeys } from "../lib/queryKeys"; | |
| import { cn, projectWorkspaceUrl } from "../lib/utils"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react"; | |
| /* -------------------------------------------------------------------------- */ | |
| /* 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; | |
| 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: { | |
| executionWorkspaceId: string | null; | |
| executionWorkspacePreference: string | null; | |
| executionWorkspaceSettings: Issue["executionWorkspaceSettings"]; | |
| currentExecutionWorkspace?: ExecutionWorkspace | null; | |
| }) { | |
| const persistedMode = | |
| issue.currentExecutionWorkspace?.mode | |
| ?? issue.executionWorkspaceSettings?.mode | |
| ?? issue.executionWorkspacePreference; | |
| return Boolean( | |
| issue.executionWorkspaceId && | |
| (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), | |
| ); | |
| } | |
| 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"; | |
| } | |
| /* -------------------------------------------------------------------------- */ | |
| /* 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(<wbr key={i} />); | |
| 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<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 ( | |
| <span className="inline-flex items-center gap-1 group/copy"> | |
| {label && <span className="text-muted-foreground">{label}</span>} | |
| <span className={cn("min-w-0", mono && "font-mono")} style={{ overflowWrap: "anywhere" }}> | |
| <BreakablePath text={value} /> | |
| </span> | |
| <button | |
| type="button" | |
| className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover/copy:opacity-100 focus:opacity-100" | |
| onClick={handleCopy} | |
| title={copied ? "Copied!" : "Copy"} | |
| > | |
| {copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />} | |
| </button> | |
| </span> | |
| ); | |
| } | |
| 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 sandbox"; | |
| 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<string, string> = { | |
| 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 ( | |
| <span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium", colors[status] ?? colors.idle)}> | |
| {status.replace(/_/g, " ")} | |
| </span> | |
| ); | |
| } | |
| /* -------------------------------------------------------------------------- */ | |
| /* 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 } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null; | |
| onUpdate: (data: Record<string, unknown>) => void; | |
| initialEditing?: boolean; | |
| livePreview?: boolean; | |
| onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void; | |
| } | |
| export function IssueWorkspaceCard({ | |
| issue, | |
| project, | |
| onUpdate, | |
| initialEditing = false, | |
| livePreview = false, | |
| onDraftChange, | |
| }: IssueWorkspaceCardProps) { | |
| const { selectedCompanyId } = useCompany(); | |
| const companyId = issue.companyId ?? selectedCompanyId; | |
| const [editing, setEditing] = useState(initialEditing); | |
| const { data: experimentalSettings } = useQuery({ | |
| queryKey: queryKeys.instance.experimentalSettings, | |
| queryFn: () => instanceSettingsApi.getExperimental(), | |
| retry: false, | |
| }); | |
| const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true | |
| && Boolean(project?.executionWorkspacePolicy?.enabled); | |
| const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; | |
| 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) && editing, | |
| }); | |
| 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((w) => w.id === issue.executionWorkspaceId) | |
| ?? workspace | |
| ?? null; | |
| const configuredSelection = shouldPresentExistingWorkspaceSelection(issue) | |
| ? "reuse_existing" | |
| : ( | |
| issue.executionWorkspacePreference | |
| ?? issue.executionWorkspaceSettings?.mode | |
| ?? defaultExecutionWorkspaceModeForProject(project) | |
| ); | |
| const currentSelection = configuredSelection === "operator_branch" || configuredSelection === "agent_default" | |
| ? "shared_workspace" | |
| : configuredSelection; | |
| const [draftSelection, setDraftSelection] = useState(currentSelection); | |
| const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); | |
| useEffect(() => { | |
| if (editing) return; | |
| setDraftSelection(currentSelection); | |
| setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); | |
| }, [currentSelection, editing, issue.executionWorkspaceId]); | |
| const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace"); | |
| const configuredReusableWorkspace = | |
| deduplicatedReusableWorkspaces.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 buildWorkspaceDraftUpdate = useCallback(() => ({ | |
| executionWorkspacePreference: draftSelection, | |
| executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null, | |
| executionWorkspaceSettings: { | |
| mode: | |
| draftSelection === "reuse_existing" | |
| ? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode) | |
| : draftSelection, | |
| }, | |
| }), [ | |
| configuredReusableWorkspace?.mode, | |
| draftExecutionWorkspaceId, | |
| draftSelection, | |
| ]); | |
| useEffect(() => { | |
| if (!onDraftChange) return; | |
| onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig }); | |
| }, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]); | |
| const handleSave = useCallback(() => { | |
| if (!canSaveWorkspaceConfig) return; | |
| onUpdate(buildWorkspaceDraftUpdate()); | |
| setEditing(false); | |
| }, [ | |
| buildWorkspaceDraftUpdate, | |
| canSaveWorkspaceConfig, | |
| onUpdate, | |
| ]); | |
| const handleCancel = useCallback(() => { | |
| setDraftSelection(currentSelection); | |
| setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); | |
| setEditing(false); | |
| }, [currentSelection, issue.executionWorkspaceId]); | |
| if (!policyEnabled || !project) return null; | |
| const showEditingControls = livePreview || editing; | |
| return ( | |
| <div className="rounded-lg border border-border p-3 space-y-2"> | |
| {/* Header row */} | |
| <div className="flex items-center justify-between gap-2"> | |
| <div className="flex items-center gap-2 text-sm font-medium text-foreground"> | |
| <GitBranch className="h-3.5 w-3.5 text-muted-foreground" /> | |
| {activeNonDefaultWorkspace && workspace | |
| ? workspaceModeLabel(workspace.mode) | |
| : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} | |
| {workspace ? statusBadge(workspace.status) : statusBadge("idle")} | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| {!livePreview && editing ? ( | |
| <> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-6 px-2 text-xs text-muted-foreground" | |
| onClick={handleCancel} | |
| > | |
| <X className="h-3 w-3 mr-1" />Cancel | |
| </Button> | |
| <Button | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={handleSave} | |
| disabled={!canSaveWorkspaceConfig} | |
| > | |
| Save | |
| </Button> | |
| </> | |
| ) : !livePreview ? ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-6 px-2 text-xs text-muted-foreground" | |
| onClick={() => setEditing(true)} | |
| > | |
| <Pencil className="h-3 w-3 mr-1" />Edit | |
| </Button> | |
| ) : null} | |
| </div> | |
| </div> | |
| {/* Read-only info */} | |
| {!showEditingControls && ( | |
| <div className="space-y-1.5 text-xs"> | |
| {workspace?.branchName && ( | |
| <div className="flex items-center gap-1.5"> | |
| <GitBranch className="h-3 w-3 text-muted-foreground shrink-0" /> | |
| <CopyableInline value={workspace.branchName} mono /> | |
| </div> | |
| )} | |
| {workspace?.cwd && ( | |
| <div className="flex items-center gap-1.5"> | |
| <FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" /> | |
| <CopyableInline value={workspace.cwd} mono /> | |
| </div> | |
| )} | |
| {workspace?.repoUrl && ( | |
| <div className="flex items-center gap-1.5 text-muted-foreground"> | |
| <span className="text-[11px]">Repo:</span> | |
| <CopyableInline value={workspace.repoUrl} mono /> | |
| </div> | |
| )} | |
| {!workspace && ( | |
| <div className="text-muted-foreground"> | |
| {currentSelection === "isolated_workspace" | |
| ? "A fresh isolated workspace will be created when this issue runs." | |
| : currentSelection === "reuse_existing" | |
| ? "This issue will reuse an existing workspace when it runs." | |
| : "This issue will use the project default workspace configuration when it runs."} | |
| </div> | |
| )} | |
| {currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && ( | |
| <div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}> | |
| Reusing:{" "} | |
| {selectedReusableWorkspaceLink ? ( | |
| <Link | |
| to={selectedReusableWorkspaceLink} | |
| className="hover:text-foreground hover:underline" | |
| > | |
| <BreakablePath text={selectedReusableExecutionWorkspace.name} /> | |
| </Link> | |
| ) : ( | |
| <BreakablePath text={selectedReusableExecutionWorkspace.name} /> | |
| )} | |
| </div> | |
| )} | |
| {workspace && currentWorkspaceLink && ( | |
| <div className="pt-0.5"> | |
| <Link | |
| to={currentWorkspaceLink} | |
| className="text-[11px] text-muted-foreground hover:text-foreground hover:underline" | |
| > | |
| View workspace details → | |
| </Link> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Editing controls */} | |
| {showEditingControls && ( | |
| <div className="space-y-2 pt-1"> | |
| <select | |
| className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" | |
| value={draftSelection} | |
| onChange={(e) => { | |
| const nextMode = e.target.value; | |
| setDraftSelection(nextMode); | |
| if (nextMode !== "reuse_existing") { | |
| setDraftExecutionWorkspaceId(""); | |
| } else if (!draftExecutionWorkspaceId && issue.executionWorkspaceId) { | |
| setDraftExecutionWorkspaceId(issue.executionWorkspaceId); | |
| } | |
| }} | |
| > | |
| {EXECUTION_WORKSPACE_OPTIONS.map((option) => ( | |
| <option key={option.value} value={option.value}> | |
| {option.value === "reuse_existing" && configuredReusableWorkspace?.mode === "isolated_workspace" | |
| ? "Existing isolated workspace" | |
| : option.label} | |
| </option> | |
| ))} | |
| </select> | |
| {draftSelection === "reuse_existing" && ( | |
| <select | |
| className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" | |
| value={draftExecutionWorkspaceId} | |
| onChange={(e) => { | |
| setDraftExecutionWorkspaceId(e.target.value); | |
| }} | |
| > | |
| <option value="">Choose an existing workspace</option> | |
| {deduplicatedReusableWorkspaces.map((w) => ( | |
| <option key={w.id} value={w.id}> | |
| {w.name} · {w.status} · {w.branchName ?? w.cwd ?? w.id.slice(0, 8)} | |
| </option> | |
| ))} | |
| </select> | |
| )} | |
| {/* Current workspace summary when editing */} | |
| {workspace && ( | |
| <div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50"> | |
| <div style={{ overflowWrap: "anywhere" }}> | |
| Current:{" "} | |
| {currentWorkspaceLink ? ( | |
| <Link | |
| to={currentWorkspaceLink} | |
| className="hover:text-foreground hover:underline" | |
| > | |
| <BreakablePath text={workspace.name} /> | |
| </Link> | |
| ) : ( | |
| <BreakablePath text={workspace.name} /> | |
| )} | |
| {" · "} | |
| {workspace.status} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |