mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task properties pane lets an operator change the settings for one task. > - The pane did not let an operator select the execution workspace for the next run. > - The existing workspace card had selection rules that another control could copy by mistake. > - This pull request adds one shared selection module and one compact property picker. > - The picker keeps the current workspace active until the next run. > - The benefit is a clear workspace choice in the task properties pane without an API change. ## Linked Issues or Issue Description **Subsystem affected** ui/ — React + Vite board UI **Problem or motivation** An operator cannot set the execution workspace from the new task properties pane. The existing task workspace card also owns selection rules that a second control could copy and change over time. **Proposed solution** Add a compact workspace property picker. Put the shared selection and update rules in one UI module. Show the picker only when isolated workspaces and the project workspace policy are enabled. **Alternatives considered** The existing workspace card could remain the only control. This would leave the new task interface incomplete. The picker could also copy the card logic, but that would create two sources of truth. **Roadmap alignment** This is a small UI improvement for existing workspace controls. It does not duplicate a planned item in `ROADMAP.md`. Related workspace work: Refs #12682. That pull request changes runner recovery and other workspace controls. It does not add this task property picker. ## What Changed - Added shared helpers for the current workspace selection and its issue update payload. - Updated the existing workspace card to use the shared helpers without changing its project-default behavior. - Added a gated workspace property picker with mode and workspace search steps. - Added unit and component tests for visibility, selection payloads, search, and workspace reuse. - Rebased onto the upstream native-run teardown fix so CI drains background heartbeat writes before PostgreSQL cleanup. ## Verification - `NODE_ENV=test pnpm --filter @paperclipai/ui exec vitest run src/lib/issue-workspace-selection.test.ts src/components/IssueProperties.test.tsx src/components/RoutineRunVariablesDialog.test.tsx` — 74 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `NODE_ENV=test pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/native-question-bridge.test.ts` — 8 tests passed on the rebased head. The upstream teardown drain prevents the prior PostgreSQL cleanup deadlock. ## Risks - Low risk. This is a gated UI-only change. - A wrong selection payload could change the next workspace mode. Exact payload tests cover every mode. - The old card must keep its existing project-default payload. A shared-helper test covers that payload. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The exact serving revision and context window are not exposed. The agent used reasoning, tool use, and code 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 (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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>
532 lines
20 KiB
TypeScript
532 lines
20 KiB
TypeScript
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 { 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,
|
|
});
|
|
|
|
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) return;
|
|
onDraftChange(buildWorkspaceDraftUpdate(), {
|
|
canSave: canSaveWorkspaceConfig,
|
|
workspaceBranchName: draftWorkspaceBranchName,
|
|
});
|
|
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!canSaveWorkspaceConfig) return;
|
|
const update = buildWorkspaceDraftUpdate();
|
|
if (!update) return;
|
|
onUpdate(update);
|
|
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">
|
|
{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 && (
|
|
<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>
|
|
);
|
|
}
|