diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 63f8d1eb138..74e9e659ffc 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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( + '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"; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index a3d419255b6..4f31865c21c 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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} - {copied && } + {copied && ( + + + Copied + + )} ); } @@ -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(null); + const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(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(); @@ -2137,34 +2189,41 @@ export function IssueProperties({ )} - {liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( + {hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( <>
- {liveWorkspaceService?.url && ( - - - {liveWorkspaceService.url} - - - - )} {showWorkspaceDetailLink && issue.executionWorkspaceId && ( + View workspace - + )} + {hasWorkspaceRuntimeControls && ( + +
+ controlWorkspaceRuntime.mutate(request)} + square + /> + {runtimeActionMessage ? ( + {runtimeActionMessage} + ) : null} + {runtimeActionErrorMessage ? ( + {runtimeActionErrorMessage} + ) : null} +
+
+ )} {issue.currentExecutionWorkspace?.branchName && (