mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Workspace isolation keeps task checkouts separate. > - Managed hosts can enable isolation and hide its experimental toggles. > - Project, task, and routine forms still expose choices that override that policy. > - This pull request adds an operator visibility key for those controls. > - Workspace access stays available, and execution keeps its existing policy. ## Linked Issues or Issue Description **What existing behavior does this improve?** Operator control over workspace isolation settings in the UI. This follows the settings list cleanup in #13905. **Current behavior** Hiding the experimental isolation toggles leaves project policy editors, task selectors, routine and pipeline overrides, recovery actions, and workspace configuration visible. **Proposed behavior** Set `PAPERCLIP_HIDDEN_SETTINGS=workspaces.isolation` to hide these controls. Keep workspace navigation, status, files, and runtime access. Hide experimental toggles separately. Instances that do not set this key keep their controls. **Reason and benefit** Users on managed hosts should use the host's isolation default. A hidden form must not submit a stale draft that overrides it. ## What Changed - Add the UI-only `workspaces.isolation` key to the shared visibility registry. - Hide project workspace policy, task and subtask selectors, routine and pipeline overrides, and isolated re-issue actions. - Hide the workspace Configuration tab and redirect direct links to workspace issues. - Omit hidden new-task and routine overrides. Keep explicit task/subtask workspace launch context, saved policies, and automatic branch values for workspace routine runs. - Wait for the health visibility policy before showing controls. Keep workspace access and all execution APIs available. - Document the key and test visibility, form payloads, deep links, and unchanged workspace access. ## Verification - All 52 CI checks pass on `50cc770d33` (two expected skips). The branch is mergeable. Greptile is 5/5 with no unresolved comments. - `pnpm -r typecheck` passed. - `pnpm build` passed. - `pnpm check:token-gates` passed. - Targeted UI checks passed: 346 tests across 14 suites, including hidden project/task controls, stale task drafts, routine branch defaults, recovery actions, configuration deep links, and workspace access. - Shared settings-visibility tests passed: 11 tests. - `pnpm test:run` was run and stopped after reproducing five failures in unchanged server tests: two `chat-channels.integration` cases (linked-request provenance and direct external-chat finals) and three `company-skills-service` cases (runtime refresh, concurrent download, and explicit update). The earlier local run for #13905 showed the same failures. The full local suite is not claimed green. Targeted UI/shared checks pass. All PR CI shards, including the affected chat and skills suites, pass. - Reviewed the diff for secrets, private links, and run artifacts. ## Risks This key changes UI visibility only. It does not reject API calls or change feature values. Operators must enable isolation and its default through their existing policy mechanism. Older app versions ignore the new key until upgraded. Removing the key restores the controls. No schema changes. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally; targeted checks pass (full-suite limitation documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
537 lines
21 KiB
TypeScript
537 lines
21 KiB
TypeScript
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(<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);
|
|
|
|
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 (
|
|
<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"}
|
|
aria-label={copied ? "Copied to clipboard" : `Copy ${label ?? "value"}`}
|
|
>
|
|
{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 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<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 (
|
|
<Badge variant="ghost" className={cn("text-(length:--text-nano) px-1.5", colors[status] ?? colors.idle)}>
|
|
{status.replace(/_/g, " ")}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* 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<string, unknown>) => void;
|
|
initialEditing?: boolean;
|
|
livePreview?: boolean;
|
|
onDraftChange?: (data: Record<string, unknown> | 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 (
|
|
<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>
|
|
{workspaceIsolationControlsVisible && (
|
|
<div className="flex items-center gap-1">
|
|
{showEditingControls ? (
|
|
<>
|
|
<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>
|
|
</>
|
|
) : (
|
|
<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>
|
|
)}
|
|
</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 && !hideHostPaths && (
|
|
<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-(length:--text-micro)">Repo:</span>
|
|
<CopyableInline value={workspace.repoUrl} mono />
|
|
</div>
|
|
)}
|
|
{environmentsEnabled && currentEnvironmentId && (
|
|
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
|
|
Environment: <span className="text-foreground">{currentEnvironment?.name ?? currentEnvironmentId}</span>
|
|
{currentSelection === "reuse_existing" && currentReusableEnvironmentId === currentEnvironmentId
|
|
? " · reused workspace"
|
|
: !issue.executionWorkspaceSettings?.environmentId && projectEnvironmentId === currentEnvironmentId
|
|
? " · project default"
|
|
: null}
|
|
</div>
|
|
)}
|
|
{!workspace && (
|
|
<div className="text-muted-foreground">
|
|
{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."}
|
|
</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-(length:--text-micro) text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
View workspace details →
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Editing controls */}
|
|
{editing && workspaceIsolationControlsVisible && (
|
|
<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 as typeof draftSelection;
|
|
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" && (
|
|
<ReusableExecutionWorkspaceSelect
|
|
value={draftExecutionWorkspaceId}
|
|
workspaces={selectableReusableWorkspaces}
|
|
onValueChange={(workspaceId) => setDraftExecutionWorkspaceId(workspaceId)}
|
|
loading={reusableExecutionWorkspacesLoading}
|
|
error={reusableExecutionWorkspacesError}
|
|
/>
|
|
)}
|
|
|
|
{/* Current workspace summary when editing */}
|
|
{workspace && (
|
|
<div className="text-(length:--text-micro) 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>
|
|
)}
|
|
|
|
{/* Workspace file discovery — calm row under the workspace identity. */}
|
|
{!showEditingControls && onBrowseFiles && (
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border/50 pt-2 text-xs">
|
|
<button
|
|
type="button"
|
|
onClick={onBrowseFiles}
|
|
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<FolderSearch className="h-3.5 w-3.5 shrink-0" />
|
|
Browse files…
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onOpenFileByPath ?? onBrowseFiles}
|
|
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<FileSearch className="h-3.5 w-3.5 shrink-0" />
|
|
Open file by path…
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|