Add issue workspace property controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-16 10:02:03 +00:00
1 parent 9a52088266
commit d359af9e63
2 files changed
+170 -37

No files matched your search

+82 -8
View File
@@ -26,6 +26,10 @@ const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
controlRuntimeCommands: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
@@ -50,6 +54,10 @@ vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
@@ -379,6 +387,7 @@ describe("IssueProperties", () => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
@@ -722,9 +731,17 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
it("shows workspace runtime controls below the workspace row for a non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const updatedWorkspace = createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "stopped", stoppedAt: new Date("2026-04-06T12:06:00.000Z") })],
});
mockExecutionWorkspacesApi.controlRuntimeCommands.mockResolvedValue({
workspace: updatedWorkspace,
operation: {},
});
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
@@ -732,7 +749,17 @@ describe("IssueProperties", () => {
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
config: {
environmentId: null,
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running", configIndex: 0 })],
}),
}),
childIssues: [],
@@ -740,13 +767,25 @@ describe("IssueProperties", () => {
});
await flush();
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
expect(serviceLink).not.toBeNull();
expect(serviceLink?.getAttribute("target")).toBe("_blank");
expect(serviceLink?.className).toContain("text-emerald");
expect((container.textContent ?? "").indexOf("Service")).toBeLessThan(
(container.textContent ?? "").indexOf("Workspace"),
expect(container.querySelector(`a[href="${serviceUrl}"]`)).not.toBeNull();
expect((container.textContent ?? "").indexOf("Workspace")).toBeLessThan(
(container.textContent ?? "").indexOf("Service"),
);
const stopButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Stop");
expect(stopButton).not.toBeUndefined();
await act(async () => {
stopButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockExecutionWorkspacesApi.controlRuntimeCommands).toHaveBeenCalledWith(
"workspace-1",
"stop",
expect.objectContaining({ action: "stop", runtimeServiceId: "service-1" }),
);
expect(container.textContent).toContain("Workspace service stopped.");
act(() => root.unmount());
});
@@ -797,6 +836,41 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("copies branch and folder values with visible feedback", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const root = renderProperties(container, {
issue: createIssue({
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
branchName: "pap-1-workspace",
cwd: "/tmp/paperclip/PAP-1",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const branchCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
expect(branchCopyButton).not.toBeNull();
await act(async () => {
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(writeText).toHaveBeenCalledWith("pap-1-workspace");
expect(container.textContent).toContain("Copied");
act(() => root.unmount());
});
it("does not show a service link for the main shared workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
+88 -29
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
import type { Issue, IssueLabel, Project } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterModel } from "../api/agents";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -57,6 +58,11 @@ import {
type HandoffChipResolvers,
} from "./interrupt-handoff/InterruptHandoffViews";
import { describeReassignInterrupt } from "../lib/interrupt-handoff";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
type WorkspaceRuntimeControlRequest,
} from "./WorkspaceRuntimeControls";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
const [copied, setCopied] = useState(false);
@@ -78,11 +84,17 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C
type="button"
className="text-sm font-mono min-w-0 break-all text-left cursor-pointer hover:text-foreground transition-colors"
onClick={handleCopy}
title={copied ? "Copied!" : "Click to copy"}
title={copied ? "Copied" : "Copy to clipboard"}
aria-label={`Copy ${value} to clipboard`}
>
{value}
</button>
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
{copied && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-300" role="status">
<Check className="h-3 w-3 shrink-0" />
Copied
</span>
)}
</div>
);
}
@@ -128,12 +140,6 @@ function isMainIssueWorkspace(input: {
return linkedProjectWorkspaceId === primaryWorkspaceId;
}
function runningRuntimeServiceWithUrl(
runtimeServices: WorkspaceRuntimeService[] | null | undefined,
) {
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
}
function toDateTimeLocalValue(value: string | null | undefined) {
if (!value) return "";
const date = new Date(value);
@@ -424,6 +430,8 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({
@@ -525,10 +533,54 @@ export function IssueProperties({
[issue, issueProject],
);
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
const liveWorkspaceService = useMemo(() => {
if (issueUsesMainWorkspace) return null;
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
}, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]);
const workspaceRuntimeConfig = issueUsesMainWorkspace
? null
: issue.currentExecutionWorkspace?.config?.workspaceRuntime ?? null;
const workspaceRuntimeServices = issue.currentExecutionWorkspace?.runtimeServices ?? [];
const workspaceCanRunCommands = Boolean(issue.currentExecutionWorkspace?.cwd);
const workspaceCanStartServices = Boolean(workspaceRuntimeConfig) && workspaceCanRunCommands;
const workspaceRuntimeSections = useMemo(() => buildWorkspaceRuntimeControlSections({
runtimeConfig: workspaceRuntimeConfig,
runtimeServices: workspaceRuntimeServices,
canStartServices: workspaceCanStartServices,
canRunJobs: workspaceCanRunCommands,
}), [workspaceCanRunCommands, workspaceCanStartServices, workspaceRuntimeConfig, workspaceRuntimeServices]);
const hasWorkspaceRuntimeControls = !issueUsesMainWorkspace && (
workspaceRuntimeSections.services.length > 0
|| workspaceRuntimeSections.otherServices.length > 0
);
const controlWorkspaceRuntime = useMutation({
mutationFn: (request: WorkspaceRuntimeControlRequest) => {
const workspaceId = issue.currentExecutionWorkspace?.id ?? issue.executionWorkspaceId;
if (!workspaceId) throw new Error("This task is not attached to a workspace.");
return executionWorkspacesApi.controlRuntimeCommands(workspaceId, request.action, request);
},
onSuccess: (result, request) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
if (companyId) {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
}
setRuntimeActionErrorMessage(null);
setRuntimeActionMessage(
request.action === "run"
? "Workspace job completed."
: request.action === "stop"
? "Workspace service stopped."
: request.action === "restart"
? "Workspace service restarted."
: "Workspace service started.",
);
},
onError: (error) => {
setRuntimeActionMessage(null);
setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
},
});
const pendingWorkspaceRuntimeAction = controlWorkspaceRuntime.isPending ? controlWorkspaceRuntime.variables ?? null : null;
const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? [];
const relatedTasks = useMemo(() => {
const excluded = new Set<string>();
@@ -2137,34 +2189,41 @@ export function IssueProperties({
)}
</div>
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<>
<Separator />
<div className="space-y-1">
{liveWorkspaceService?.url && (
<PropertyRow label="Service">
<a
href={liveWorkspaceService.url}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-start gap-1 text-sm font-mono text-emerald-700 hover:text-emerald-800 hover:underline dark:text-emerald-300 dark:hover:text-emerald-200"
>
<span className="min-w-0 break-all">{liveWorkspaceService.url}</span>
<ExternalLink className="mt-1 h-3 w-3 shrink-0" />
</a>
</PropertyRow>
)}
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
className="text-sm text-primary hover:underline inline-flex min-w-0 items-center gap-1.5"
>
<Hexagon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
View workspace
<ExternalLink className="h-3 w-3" />
<ExternalLink className="h-3 w-3 shrink-0" />
</Link>
</PropertyRow>
)}
{hasWorkspaceRuntimeControls && (
<PropertyRow label="Service">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<WorkspaceRuntimeQuickControls
sections={workspaceRuntimeSections}
isPending={controlWorkspaceRuntime.isPending}
pendingRequest={pendingWorkspaceRuntimeAction}
onAction={(request) => controlWorkspaceRuntime.mutate(request)}
square
/>
{runtimeActionMessage ? (
<span className="text-xs text-muted-foreground" role="status">{runtimeActionMessage}</span>
) : null}
{runtimeActionErrorMessage ? (
<span className="text-xs text-destructive" role="alert">{runtimeActionErrorMessage}</span>
) : null}
</div>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.branchName && (
<PropertyRow label="Branch">
<TruncatedCopyable