fix(ui): address workspace card review feedback

- restore pre-run workspace configuration visibility
- require explicit save/cancel for workspace edits
- stabilize debounced issue search callback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 11:53:25 -05:00
parent dd8c1ca3b2
commit ab0d04ff7a
2 changed files with 148 additions and 52 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import type { Issue, ExecutionWorkspace } from "@paperclipai/shared"; import type { Issue, ExecutionWorkspace } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -98,6 +98,22 @@ function workspaceModeLabel(mode: string | null | undefined) {
} }
} }
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 statusBadge(status: string) { function statusBadge(status: string) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
active: "bg-green-500/15 text-green-700 dark:text-green-400", active: "bg-green-500/15 text-green-700 dark:text-green-400",
@ -137,9 +153,6 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
// Only show this card for non-default workspaces
const isNonDefault = workspace && workspace.mode !== "shared_workspace";
const { data: reusableExecutionWorkspaces } = useQuery({ const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, { queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined, projectId: issue.projectId ?? undefined,
@ -181,8 +194,51 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
?? defaultExecutionWorkspaceModeForProject(project) ?? defaultExecutionWorkspaceModeForProject(project)
); );
// Don't render if feature is off or workspace is default/absent const [draftSelection, setDraftSelection] = useState(currentSelection);
if (!policyEnabled || !isNonDefault) return null; 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 canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
onUpdate({
executionWorkspacePreference: draftSelection,
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
executionWorkspaceSettings: {
mode:
draftSelection === "reuse_existing"
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
: draftSelection,
},
});
setEditing(false);
}, [
canSaveWorkspaceConfig,
configuredReusableWorkspace?.mode,
draftExecutionWorkspaceId,
draftSelection,
onUpdate,
]);
const handleCancel = useCallback(() => {
setDraftSelection(currentSelection);
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
setEditing(false);
}, [currentSelection, issue.executionWorkspaceId]);
if (!policyEnabled || !project) return null;
return ( return (
<div className="rounded-lg border border-border p-3 space-y-2"> <div className="rounded-lg border border-border p-3 space-y-2">
@ -190,48 +246,95 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground"> <div className="flex items-center gap-2 text-sm font-medium text-foreground">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" /> <GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
{workspaceModeLabel(workspace.mode)} {activeNonDefaultWorkspace && workspace
{statusBadge(workspace.status)} ? workspaceModeLabel(workspace.mode)
: configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)}
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
</div>
<div className="flex items-center gap-1">
{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>
</>
) : (
<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>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => setEditing(!editing)}
>
{editing ? <><X className="h-3 w-3 mr-1" />Cancel</> : <><Pencil className="h-3 w-3 mr-1" />Edit</>}
</Button>
</div> </div>
{/* Read-only info */} {/* Read-only info */}
{!editing && ( {!editing && (
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-xs">
{workspace.branchName && ( {workspace?.branchName && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-muted-foreground shrink-0" /> <GitBranch className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.branchName} mono /> <CopyableInline value={workspace.branchName} mono />
</div> </div>
)} )}
{workspace.cwd && ( {workspace?.cwd && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" /> <FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.cwd} mono /> <CopyableInline value={workspace.cwd} mono />
</div> </div>
)} )}
{workspace.repoUrl && ( {workspace?.repoUrl && (
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground">
<span className="text-[11px]">Repo:</span> <span className="text-[11px]">Repo:</span>
<CopyableInline value={workspace.repoUrl} mono /> <CopyableInline value={workspace.repoUrl} mono />
</div> </div>
)} )}
<div className="pt-0.5"> {!workspace && (
<Link <div className="text-muted-foreground">
to={`/execution-workspaces/${workspace.id}`} {currentSelection === "isolated_workspace"
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline" ? "A fresh isolated workspace will be created when this issue runs."
> : currentSelection === "reuse_existing"
View workspace details ? "This issue will reuse an existing workspace when it runs."
</Link> : "This issue will use the project default workspace configuration when it runs."}
</div> </div>
)}
{currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
Reusing:{" "}
<Link
to={`/execution-workspaces/${selectedReusableExecutionWorkspace.id}`}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
</Link>
</div>
)}
{workspace && (
<div className="pt-0.5">
<Link
to={`/execution-workspaces/${workspace.id}`}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
View workspace details
</Link>
</div>
)}
</div> </div>
)} )}
@ -240,44 +343,32 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<select <select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={currentSelection} value={draftSelection}
onChange={(e) => { onChange={(e) => {
const nextMode = e.target.value; const nextMode = e.target.value;
onUpdate({ setDraftSelection(nextMode);
executionWorkspacePreference: nextMode, if (nextMode !== "reuse_existing") {
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null, setDraftExecutionWorkspaceId("");
executionWorkspaceSettings: { } else if (!draftExecutionWorkspaceId && issue.executionWorkspaceId) {
mode: setDraftExecutionWorkspaceId(issue.executionWorkspaceId);
nextMode === "reuse_existing" }
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: nextMode,
},
});
}} }}
> >
{EXECUTION_WORKSPACE_OPTIONS.map((option) => ( {EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace" {option.value === "reuse_existing" && configuredReusableWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace" ? "Existing isolated workspace"
: option.label} : option.label}
</option> </option>
))} ))}
</select> </select>
{currentSelection === "reuse_existing" && ( {draftSelection === "reuse_existing" && (
<select <select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={issue.executionWorkspaceId ?? ""} value={draftExecutionWorkspaceId}
onChange={(e) => { onChange={(e) => {
const nextId = e.target.value || null; setDraftExecutionWorkspaceId(e.target.value);
const next = deduplicatedReusableWorkspaces.find((w) => w.id === nextId);
onUpdate({
executionWorkspacePreference: "reuse_existing",
executionWorkspaceId: nextId,
executionWorkspaceSettings: {
mode: issueModeForExistingWorkspace(next?.mode),
},
});
}} }}
> >
<option value="">Choose an existing workspace</option> <option value="">Choose an existing workspace</option>

View file

@ -182,17 +182,22 @@ interface IssuesSearchInputProps {
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) { function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const onValueCommittedRef = useRef(onValueCommitted);
useEffect(() => { useEffect(() => {
setValue(initialValue); setValue(initialValue);
}, [initialValue]); }, [initialValue]);
useEffect(() => {
onValueCommittedRef.current = onValueCommitted;
}, [onValueCommitted]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
onValueCommitted(value); onValueCommittedRef.current(value);
}, ISSUE_SEARCH_COMMIT_DELAY_MS); }, ISSUE_SEARCH_COMMIT_DELAY_MS);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [value, onValueCommitted]); }, [value]);
return ( return (
<div className="relative w-48 sm:w-64 md:w-80"> <div className="relative w-48 sm:w-64 md:w-80">