mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Add issue workspace property controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9a52088266
commit
d359af9e63
2 files changed
+170
-37
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user