mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Improve workspace navigation and runtime UI (#4089)
## Thinking Path > - Paperclip agents do real work in project and execution workspaces. > - Operators need workspace state to be visible, navigable, and copyable without digging through raw run logs. > - The branch included related workspace cards, navigation, runtime controls, stale-service handling, and issue-property visibility. > - These changes share the workspace UI and runtime-control surfaces and can stand alone from unrelated access/profile work. > - This pull request groups the workspace experience changes into one standalone branch. > - The benefit is a clearer workspace overview, better metadata copy flows, and more accurate runtime service controls. ## What Changed - Polished project workspace summary cards and made workspace metadata copyable. - Added a workspace navigation overview and extracted reusable project workspace content. - Squared and polished the execution workspace configuration page. - Fixed stale workspace command matching and hid stopped stale services in runtime controls. - Showed live workspace service context in issue properties. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/lib/project-workspaces-tab.test.ts ui/src/components/Sidebar.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/components/IssueProperties.test.tsx` - `pnpm exec vitest run packages/shared/src/workspace-commands.test.ts --config /dev/null` because the root Vitest project config does not currently include `packages/shared` tests. - Split integration check: merged after runtime/governance, dev-infra/backups, and access/profiles with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches workspace navigation, runtime controls, and issue property rendering. - Visual layout changes may need browser QA, especially around smaller screens and dense workspace metadata. - No database migrations are included. > 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, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -53,4 +53,26 @@ describe("workspace command helpers", () => {
|
|||||||
|
|
||||||
expect(match).toEqual(expect.objectContaining({ id: "runtime-web" }));
|
expect(match).toEqual(expect.objectContaining({ id: "runtime-web" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not match a stale runtime service after the configured command changes", () => {
|
||||||
|
const workspaceRuntime = {
|
||||||
|
commands: [
|
||||||
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth", cwd: "." },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const command = findWorkspaceCommandDefinition(workspaceRuntime, "web");
|
||||||
|
expect(command).not.toBeNull();
|
||||||
|
|
||||||
|
const match = matchWorkspaceRuntimeServiceToCommand(command!, [
|
||||||
|
{
|
||||||
|
id: "runtime-web",
|
||||||
|
serviceName: "web",
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/repo",
|
||||||
|
configIndex: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(match).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ export function scoreWorkspaceRuntimeServiceMatch(
|
|||||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||||
runtimeService: Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
runtimeService: Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
|
||||||
) {
|
) {
|
||||||
|
if (command.command && runtimeService.command && runtimeService.command !== command.command) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) {
|
if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) {
|
||||||
return runtimeService.configIndex === command.serviceIndex ? 100 : -1;
|
return runtimeService.configIndex === command.serviceIndex ? 100 : -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AgentDetail } from "./pages/AgentDetail";
|
|||||||
import { Projects } from "./pages/Projects";
|
import { Projects } from "./pages/Projects";
|
||||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||||
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||||
|
import { Workspaces } from "./pages/Workspaces";
|
||||||
import { Issues } from "./pages/Issues";
|
import { Issues } from "./pages/Issues";
|
||||||
import { IssueDetail } from "./pages/IssueDetail";
|
import { IssueDetail } from "./pages/IssueDetail";
|
||||||
import { Routines } from "./pages/Routines";
|
import { Routines } from "./pages/Routines";
|
||||||
@@ -90,6 +91,7 @@ function boardRoutes() {
|
|||||||
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||||
|
<Route path="workspaces" element={<Workspaces />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||||
@@ -296,6 +298,7 @@ export function App() {
|
|||||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CopyTextProps {
|
interface CopyTextProps {
|
||||||
text: string;
|
text: string;
|
||||||
/** What to display. Defaults to `text`. */
|
/** What to display. Defaults to `text`. */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
containerClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
title?: string;
|
||||||
/** Tooltip message shown after copying. Default: "Copied!" */
|
/** Tooltip message shown after copying. Default: "Copied!" */
|
||||||
copiedLabel?: string;
|
copiedLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) {
|
export function CopyText({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
containerClassName,
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
title,
|
||||||
|
copiedLabel = "Copied!",
|
||||||
|
}: CopyTextProps) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [label, setLabel] = useState(copiedLabel);
|
const [label, setLabel] = useState(copiedLabel);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
const handleClick = useCallback(async () => {
|
const handleClick = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
@@ -45,10 +58,12 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }:
|
|||||||
}, [copiedLabel, text]);
|
}, [copiedLabel, text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex">
|
<span className={cn("relative inline-flex", containerClassName)}>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-copy hover:text-foreground transition-colors",
|
"cursor-copy hover:text-foreground transition-colors",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
import type {
|
||||||
|
ExecutionWorkspace,
|
||||||
|
IssueExecutionPolicy,
|
||||||
|
IssueExecutionState,
|
||||||
|
Project,
|
||||||
|
WorkspaceRuntimeService,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -145,6 +151,132 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
||||||
|
return {
|
||||||
|
id: "service-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-main",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
scopeType: "execution_workspace",
|
||||||
|
scopeId: "workspace-1",
|
||||||
|
serviceName: "web",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey: null,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip",
|
||||||
|
port: 62475,
|
||||||
|
url: "http://127.0.0.1:62475",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||||
|
startedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: null,
|
||||||
|
healthStatus: "healthy",
|
||||||
|
createdAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||||
|
return {
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-main",
|
||||||
|
sourceIssueId: "issue-1",
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "PAP-1 workspace",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/paperclip/PAP-1",
|
||||||
|
repoUrl: null,
|
||||||
|
baseRef: "master",
|
||||||
|
branchName: "pap-1-workspace",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
providerRef: "/tmp/paperclip/PAP-1",
|
||||||
|
derivedFromExecutionWorkspaceId: null,
|
||||||
|
lastUsedAt: new Date("2026-04-06T12:04:00.000Z"),
|
||||||
|
openedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
closedAt: null,
|
||||||
|
cleanupEligibleAt: null,
|
||||||
|
cleanupReason: null,
|
||||||
|
config: null,
|
||||||
|
metadata: null,
|
||||||
|
runtimeServices: [createRuntimeService()],
|
||||||
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(overrides: Partial<Project> = {}): Project {
|
||||||
|
const primaryWorkspace = {
|
||||||
|
id: "workspace-main",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Main",
|
||||||
|
sourceType: "local_path" as const,
|
||||||
|
cwd: "/tmp/paperclip",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: "master",
|
||||||
|
visibility: "default" as const,
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
runtimeConfig: null,
|
||||||
|
isPrimary: true,
|
||||||
|
runtimeServices: [],
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Project",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: "#6366f1",
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
codebase: {
|
||||||
|
workspaceId: "workspace-main",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: "master",
|
||||||
|
repoName: null,
|
||||||
|
localFolder: "/tmp/paperclip",
|
||||||
|
managedFolder: "/tmp/paperclip",
|
||||||
|
effectiveLocalFolder: "/tmp/paperclip",
|
||||||
|
origin: "local_folder",
|
||||||
|
},
|
||||||
|
workspaces: [primaryWorkspace],
|
||||||
|
primaryWorkspace,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
|
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
|
||||||
return {
|
return {
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
@@ -229,6 +361,59 @@ describe("IssueProperties", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
|
||||||
|
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||||
|
const serviceUrl = "http://127.0.0.1:62475";
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-main",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
currentExecutionWorkspace: createExecutionWorkspace({
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
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"),
|
||||||
|
);
|
||||||
|
|
||||||
|
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";
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-main",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
currentExecutionWorkspace: createExecutionWorkspace({
|
||||||
|
mode: "shared_workspace",
|
||||||
|
projectWorkspaceId: "workspace-main",
|
||||||
|
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull();
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("shows an add-label button when labels already exist and opens the picker", async () => {
|
it("shows an add-label button when labels already exist and opens the picker", async () => {
|
||||||
const root = renderProperties(container, {
|
const root = renderProperties(container, {
|
||||||
issue: createIssue({
|
issue: createIssue({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -72,6 +72,35 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
|||||||
return "shared_workspace";
|
return "shared_workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function primaryWorkspaceIdForProject(project: Pick<Project, "primaryWorkspace" | "workspaces"> | null | undefined) {
|
||||||
|
return project?.primaryWorkspace?.id
|
||||||
|
?? project?.workspaces.find((workspace) => workspace.isPrimary)?.id
|
||||||
|
?? project?.workspaces[0]?.id
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMainIssueWorkspace(input: {
|
||||||
|
issue: Pick<Issue, "projectWorkspaceId" | "currentExecutionWorkspace">;
|
||||||
|
project: Pick<Project, "primaryWorkspace" | "workspaces"> | null | undefined;
|
||||||
|
}) {
|
||||||
|
const workspace = input.issue.currentExecutionWorkspace ?? null;
|
||||||
|
const primaryWorkspaceId = primaryWorkspaceIdForProject(input.project);
|
||||||
|
const linkedProjectWorkspaceId = workspace?.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null;
|
||||||
|
if (workspace) {
|
||||||
|
if (workspace.mode !== "shared_workspace") return false;
|
||||||
|
if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true;
|
||||||
|
return workspace.mode === "shared_workspace" && linkedProjectWorkspaceId === primaryWorkspaceId;
|
||||||
|
}
|
||||||
|
if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true;
|
||||||
|
return linkedProjectWorkspaceId === primaryWorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runningRuntimeServiceWithUrl(
|
||||||
|
runtimeServices: WorkspaceRuntimeService[] | null | undefined,
|
||||||
|
) {
|
||||||
|
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
childIssues?: Issue[];
|
childIssues?: Issue[];
|
||||||
@@ -253,6 +282,11 @@ export function IssueProperties({
|
|||||||
const currentProject = issue.projectId
|
const currentProject = issue.projectId
|
||||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
const issueProject = issue.project ?? currentProject;
|
||||||
|
const liveWorkspaceService = useMemo(() => {
|
||||||
|
if (isMainIssueWorkspace({ issue, project: issueProject })) return null;
|
||||||
|
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
|
||||||
|
}, [issue, issueProject]);
|
||||||
const projectLink = (id: string | null) => {
|
const projectLink = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const project = projects?.find((p) => p.id === id) ?? null;
|
const project = projects?.find((p) => p.id === id) ?? null;
|
||||||
@@ -1117,10 +1151,23 @@ export function IssueProperties({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-1">
|
<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>
|
||||||
|
)}
|
||||||
{issue.executionWorkspaceId && (
|
{issue.executionWorkspaceId && (
|
||||||
<PropertyRow label="Workspace">
|
<PropertyRow label="Workspace">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ vi.mock("./IssuesQuicklook", () => ({
|
|||||||
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
|
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./CopyText", () => ({
|
|
||||||
CopyText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
@@ -75,6 +71,7 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
|||||||
serviceCount: overrides.serviceCount ?? 2,
|
serviceCount: overrides.serviceCount ?? 2,
|
||||||
runningServiceCount: overrides.runningServiceCount ?? 0,
|
runningServiceCount: overrides.runningServiceCount ?? 0,
|
||||||
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474",
|
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474",
|
||||||
|
primaryServiceUrlRunning: overrides.primaryServiceUrlRunning ?? false,
|
||||||
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
||||||
issues: overrides.issues ?? [
|
issues: overrides.issues ?? [
|
||||||
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
||||||
@@ -88,10 +85,20 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
|||||||
|
|
||||||
describe("ProjectWorkspaceSummaryCard", () => {
|
describe("ProjectWorkspaceSummaryCard", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
let writeClipboard: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
writeClipboard = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: writeClipboard },
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "isSecureContext", {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -124,6 +131,9 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
|||||||
|
|
||||||
const actions = container.querySelector('[data-testid="workspace-summary-actions"]');
|
const actions = container.querySelector('[data-testid="workspace-summary-actions"]');
|
||||||
expect(actions?.className).toContain("flex-col");
|
expect(actions?.className).toContain("flex-col");
|
||||||
|
const card = container.firstElementChild;
|
||||||
|
expect(card?.className).toContain("rounded-lg");
|
||||||
|
expect(card?.className).toContain("border");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -189,4 +199,87 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
|||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("copies branch and path from both text and icon controls with feedback", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const summary = createSummary({
|
||||||
|
branchName: "PAP-1552-workspace-polish",
|
||||||
|
cwd: "/Users/dotta/paperclip/.worktrees/PAP-1552-workspace-polish",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ProjectWorkspaceSummaryCard
|
||||||
|
projectRef="paperclip-app"
|
||||||
|
summary={summary}
|
||||||
|
runtimeActionKey={null}
|
||||||
|
runtimeActionPending={false}
|
||||||
|
onRuntimeAction={() => {}}
|
||||||
|
onCloseWorkspace={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchTextButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent === summary.branchName);
|
||||||
|
const pathTextButton = container.querySelector(`button[title="${summary.cwd}"]`);
|
||||||
|
const branchIconButton = container.querySelector('button[aria-label="Copy branch"]');
|
||||||
|
const pathIconButton = container.querySelector('button[aria-label="Copy path"]');
|
||||||
|
|
||||||
|
expect(branchTextButton).not.toBeNull();
|
||||||
|
expect(pathTextButton).not.toBeNull();
|
||||||
|
expect(branchIconButton).not.toBeNull();
|
||||||
|
expect(pathIconButton).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
branchTextButton!.click();
|
||||||
|
});
|
||||||
|
expect(writeClipboard).toHaveBeenLastCalledWith(summary.branchName);
|
||||||
|
expect(branchTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pathTextButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(writeClipboard).toHaveBeenLastCalledWith(summary.cwd);
|
||||||
|
expect(pathTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
branchIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
pathIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(writeClipboard).toHaveBeenCalledWith(summary.branchName);
|
||||||
|
expect(writeClipboard).toHaveBeenCalledWith(summary.cwd);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("colors live service urls green", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ProjectWorkspaceSummaryCard
|
||||||
|
projectRef="paperclip-app"
|
||||||
|
summary={createSummary({
|
||||||
|
primaryServiceUrl: "http://127.0.0.1:62475",
|
||||||
|
primaryServiceUrlRunning: true,
|
||||||
|
runningServiceCount: 1,
|
||||||
|
})}
|
||||||
|
runtimeActionKey={null}
|
||||||
|
runtimeActionPending={false}
|
||||||
|
onRuntimeAction={() => {}}
|
||||||
|
onCloseWorkspace={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceLink = container.querySelector("a[href='http://127.0.0.1:62475']");
|
||||||
|
expect(serviceLink?.className).toContain("text-emerald");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ProjectWorkspaceSummaryCard({
|
|||||||
const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`;
|
const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-4 py-4 last:border-b-0 sm:px-5">
|
<div className="rounded-lg border border-border bg-background p-4 shadow-sm sm:p-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
@@ -143,14 +143,31 @@ export function ProjectWorkspaceSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/70 bg-muted/15 px-3 py-3">
|
<div className="rounded-lg border border-border/70 bg-background px-3 py-3">
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{summary.branchName ? (
|
{summary.branchName ? (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Branch</div>
|
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Branch</div>
|
||||||
<div className="break-all font-mono text-xs text-foreground">{summary.branchName}</div>
|
<div className="flex items-start gap-2">
|
||||||
|
<CopyText
|
||||||
|
text={summary.branchName}
|
||||||
|
containerClassName="min-w-0"
|
||||||
|
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
||||||
|
copiedLabel="Branch copied"
|
||||||
|
>
|
||||||
|
{summary.branchName}
|
||||||
|
</CopyText>
|
||||||
|
<CopyText
|
||||||
|
text={summary.branchName}
|
||||||
|
ariaLabel="Copy branch"
|
||||||
|
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
copiedLabel="Branch copied"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</CopyText>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -161,10 +178,21 @@ export function ProjectWorkspaceSummaryCard({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Path</div>
|
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Path</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="min-w-0 break-all font-mono text-xs text-foreground" title={summary.cwd}>
|
<CopyText
|
||||||
|
text={summary.cwd}
|
||||||
|
title={summary.cwd}
|
||||||
|
containerClassName="min-w-0"
|
||||||
|
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
||||||
|
copiedLabel="Path copied"
|
||||||
|
>
|
||||||
{truncatePath(summary.cwd)}
|
{truncatePath(summary.cwd)}
|
||||||
</span>
|
</CopyText>
|
||||||
<CopyText text={summary.cwd} className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Path copied">
|
<CopyText
|
||||||
|
text={summary.cwd}
|
||||||
|
ariaLabel="Copy path"
|
||||||
|
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
copiedLabel="Path copied"
|
||||||
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</CopyText>
|
</CopyText>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +209,12 @@ export function ProjectWorkspaceSummaryCard({
|
|||||||
href={summary.primaryServiceUrl}
|
href={summary.primaryServiceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="break-all font-mono text-xs text-foreground hover:underline"
|
className={cn(
|
||||||
|
"break-all font-mono text-xs hover:underline",
|
||||||
|
summary.primaryServiceUrlRunning
|
||||||
|
? "text-emerald-700 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{summary.primaryServiceUrl}
|
{summary.primaryServiceUrl}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
119
ui/src/components/ProjectWorkspacesContent.tsx
Normal file
119
ui/src/components/ProjectWorkspacesContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||||
|
import { ExecutionWorkspaceCloseDialog } from "./ExecutionWorkspaceCloseDialog";
|
||||||
|
import { ProjectWorkspaceSummaryCard } from "./ProjectWorkspaceSummaryCard";
|
||||||
|
|
||||||
|
export function ProjectWorkspacesContent({
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectRef,
|
||||||
|
summaries,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectRef: string;
|
||||||
|
summaries: ProjectWorkspaceSummary[];
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
||||||
|
const [closingWorkspace, setClosingWorkspace] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: ExecutionWorkspace["status"];
|
||||||
|
} | null>(null);
|
||||||
|
const controlWorkspaceRuntime = useMutation({
|
||||||
|
mutationFn: async (input: {
|
||||||
|
key: string;
|
||||||
|
kind: "project_workspace" | "execution_workspace";
|
||||||
|
workspaceId: string;
|
||||||
|
action: "start" | "stop" | "restart";
|
||||||
|
}) => {
|
||||||
|
setRuntimeActionKey(`${input.key}:${input.action}`);
|
||||||
|
if (input.kind === "project_workspace") {
|
||||||
|
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
||||||
|
}
|
||||||
|
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setRuntimeActionKey(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (summaries.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||||
|
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeSummaries.map((summary) => (
|
||||||
|
<ProjectWorkspaceSummaryCard
|
||||||
|
key={summary.key}
|
||||||
|
projectRef={projectRef}
|
||||||
|
summary={summary}
|
||||||
|
runtimeActionKey={runtimeActionKey}
|
||||||
|
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||||
|
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||||
|
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{cleanupFailedSummaries.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Cleanup attention needed
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cleanupFailedSummaries.map((summary) => (
|
||||||
|
<ProjectWorkspaceSummaryCard
|
||||||
|
key={summary.key}
|
||||||
|
projectRef={projectRef}
|
||||||
|
summary={summary}
|
||||||
|
runtimeActionKey={runtimeActionKey}
|
||||||
|
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||||
|
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||||
|
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{closingWorkspace ? (
|
||||||
|
<ExecutionWorkspaceCloseDialog
|
||||||
|
workspaceId={closingWorkspace.id}
|
||||||
|
workspaceName={closingWorkspace.name}
|
||||||
|
currentStatus={closingWorkspace.status}
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setClosingWorkspace(null);
|
||||||
|
}}
|
||||||
|
onClosed={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||||
|
setClosingWorkspace(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
ui/src/components/Sidebar.test.tsx
Normal file
153
ui/src/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||||
|
liveRunsForCompany: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
NavLink: ({ to, children, className, ...props }: {
|
||||||
|
to: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string | ((state: { isActive: boolean }) => string);
|
||||||
|
}) => (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
className={typeof className === "function" ? className({ isActive: false }) : className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/DialogContext", () => ({
|
||||||
|
useDialog: () => ({
|
||||||
|
openNewIssue: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
|
useSidebar: () => ({
|
||||||
|
isMobile: false,
|
||||||
|
setSidebarOpen: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/heartbeats", () => ({
|
||||||
|
heartbeatsApi: mockHeartbeatsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useInboxBadge", () => ({
|
||||||
|
useInboxBadge: () => ({ inbox: 0, failedRuns: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/plugins/slots", () => ({
|
||||||
|
PluginSlotOutlet: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SidebarCompanyMenu", () => ({
|
||||||
|
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SidebarProjects", () => ({
|
||||||
|
SidebarProjects: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SidebarAgents", () => ({
|
||||||
|
SidebarAgents: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function flushReact() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Sidebar", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Sidebar />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Workspaces");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Workspaces link when isolated workspaces are enabled", async () => {
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Sidebar />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
|
||||||
|
expect(link?.getAttribute("href")).toBe("/workspaces");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Boxes,
|
Boxes,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
GitBranch,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -20,6 +21,7 @@ import { SidebarAgents } from "./SidebarAgents";
|
|||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -30,6 +32,10 @@ export function Sidebar() {
|
|||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
@@ -37,6 +43,7 @@ export function Sidebar() {
|
|||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
const liveRunCount = liveRuns?.length ?? 0;
|
const liveRunCount = liveRuns?.length ?? 0;
|
||||||
|
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||||
|
|
||||||
function openSearch() {
|
function openSearch() {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||||
@@ -94,6 +101,9 @@ export function Sidebar() {
|
|||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||||
|
{showWorkspacesLink ? (
|
||||||
|
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||||
|
) : null}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<SidebarProjects />
|
<SidebarProjects />
|
||||||
|
|||||||
@@ -76,6 +76,74 @@ describe("buildWorkspaceRuntimeControlSections", () => {
|
|||||||
workspaceCommandId: "db-migrate",
|
workspaceCommandId: "db-migrate",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps stopped stale runtime services from masking updated inherited commands", () => {
|
||||||
|
const sections = buildWorkspaceRuntimeControlSections({
|
||||||
|
runtimeConfig: {
|
||||||
|
commands: [
|
||||||
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeServices: [
|
||||||
|
createRuntimeService({
|
||||||
|
id: "service-web",
|
||||||
|
serviceName: "web",
|
||||||
|
status: "stopped",
|
||||||
|
command: "pnpm dev",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
canStartServices: true,
|
||||||
|
canRunJobs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sections.services).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "web",
|
||||||
|
statusLabel: "stopped",
|
||||||
|
command: "pnpm dev:once --tailscale-auth",
|
||||||
|
runtimeServiceId: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(sections.otherServices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces running stale runtime services separately from updated commands", () => {
|
||||||
|
const sections = buildWorkspaceRuntimeControlSections({
|
||||||
|
runtimeConfig: {
|
||||||
|
commands: [
|
||||||
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeServices: [
|
||||||
|
createRuntimeService({
|
||||||
|
id: "service-web",
|
||||||
|
serviceName: "web",
|
||||||
|
status: "running",
|
||||||
|
command: "pnpm dev",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
canStartServices: true,
|
||||||
|
canRunJobs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sections.services).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "web",
|
||||||
|
statusLabel: "stopped",
|
||||||
|
command: "pnpm dev:once --tailscale-auth",
|
||||||
|
runtimeServiceId: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(sections.otherServices).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "web",
|
||||||
|
statusLabel: "running",
|
||||||
|
command: "pnpm dev",
|
||||||
|
runtimeServiceId: "service-web",
|
||||||
|
disabledReason: "This runtime service no longer matches a configured workspace command.",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorkspaceRuntimeControlItems", () => {
|
describe("buildWorkspaceRuntimeControlItems", () => {
|
||||||
@@ -237,6 +305,42 @@ describe("WorkspaceRuntimeControls", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can render square plain surfaces for embedded configuration pages", () => {
|
||||||
|
const sections = buildWorkspaceRuntimeControlSections({
|
||||||
|
runtimeConfig: {
|
||||||
|
commands: [
|
||||||
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeServices: [],
|
||||||
|
canStartServices: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<WorkspaceRuntimeControls
|
||||||
|
sections={sections}
|
||||||
|
square
|
||||||
|
onAction={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryPanel = container.querySelector(".border.border-border\\/70");
|
||||||
|
const servicePanel = Array.from(container.querySelectorAll(".border.border-border\\/80"))
|
||||||
|
.find((element) => element.textContent?.includes("web"));
|
||||||
|
const startButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.trim() === "Start");
|
||||||
|
|
||||||
|
expect(summaryPanel?.className).toContain("rounded-none");
|
||||||
|
expect(summaryPanel?.className).not.toContain("bg-background/60");
|
||||||
|
expect(servicePanel?.className).toContain("rounded-none");
|
||||||
|
expect(startButton?.className).toContain("rounded-none");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts the legacy items prop without crashing", () => {
|
it("accepts the legacy items prop without crashing", () => {
|
||||||
const items = buildWorkspaceRuntimeControlItems({
|
const items = buildWorkspaceRuntimeControlItems({
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type WorkspaceRuntimeControlsProps = {
|
|||||||
disabledHint?: string | null;
|
disabledHint?: string | null;
|
||||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
square?: boolean;
|
||||||
} | {
|
} | {
|
||||||
sections?: never;
|
sections?: never;
|
||||||
items: LegacyWorkspaceRuntimeControlItem[];
|
items: LegacyWorkspaceRuntimeControlItem[];
|
||||||
@@ -68,6 +69,7 @@ type WorkspaceRuntimeControlsProps = {
|
|||||||
disabledHint?: string | null;
|
disabledHint?: string | null;
|
||||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
square?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hasRunningRuntimeServices(
|
export function hasRunningRuntimeServices(
|
||||||
@@ -149,7 +151,9 @@ export function buildWorkspaceRuntimeControlSections(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const otherServices = runtimeServices
|
const otherServices = runtimeServices
|
||||||
.filter((runtimeService) => !matchedRuntimeServiceIds.has(runtimeService.id))
|
.filter((runtimeService) =>
|
||||||
|
!matchedRuntimeServiceIds.has(runtimeService.id)
|
||||||
|
&& (runtimeService.status === "starting" || runtimeService.status === "running"))
|
||||||
.map((runtimeService) => ({
|
.map((runtimeService) => ({
|
||||||
key: `runtime:${runtimeService.id}`,
|
key: `runtime:${runtimeService.id}`,
|
||||||
title: runtimeService.serviceName,
|
title: runtimeService.serviceName,
|
||||||
@@ -212,11 +216,13 @@ function CommandActionButtons({
|
|||||||
isPending,
|
isPending,
|
||||||
pendingRequest,
|
pendingRequest,
|
||||||
onAction,
|
onAction,
|
||||||
|
square,
|
||||||
}: {
|
}: {
|
||||||
item: WorkspaceRuntimeControlItem;
|
item: WorkspaceRuntimeControlItem;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||||
|
square?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const actions: WorkspaceRuntimeAction[] =
|
const actions: WorkspaceRuntimeAction[] =
|
||||||
item.kind === "job"
|
item.kind === "job"
|
||||||
@@ -249,7 +255,8 @@ function CommandActionButtons({
|
|||||||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-full justify-start rounded-xl px-3 shadow-none sm:w-auto",
|
"h-9 w-full justify-start px-3 shadow-none sm:w-auto",
|
||||||
|
square ? "rounded-none" : "rounded-xl",
|
||||||
action === "restart" ? "bg-background" : null,
|
action === "restart" ? "bg-background" : null,
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -273,6 +280,7 @@ function CommandSection({
|
|||||||
isPending,
|
isPending,
|
||||||
pendingRequest,
|
pendingRequest,
|
||||||
onAction,
|
onAction,
|
||||||
|
square,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -282,6 +290,7 @@ function CommandSection({
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||||
|
square?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -290,14 +299,14 @@ function CommandSection({
|
|||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border/80 bg-background/50 px-3 py-4 text-sm text-muted-foreground">
|
<div className={cn("border border-dashed border-border/80 bg-background px-3 py-4 text-sm text-muted-foreground", square ? "rounded-none" : "rounded-xl")}>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
{disabledHint ? <p className="mt-2 text-xs">{disabledHint}</p> : null}
|
{disabledHint ? <p className="mt-2 text-xs">{disabledHint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.key} className="rounded-xl border border-border/80 bg-background px-3 py-3">
|
<div key={item.key} className={cn("border border-border/80 bg-background px-3 py-3", square ? "rounded-none" : "rounded-xl")}>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -312,6 +321,7 @@ function CommandSection({
|
|||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
pendingRequest={pendingRequest}
|
pendingRequest={pendingRequest}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
square={square}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
@@ -360,6 +370,7 @@ export function WorkspaceRuntimeControls({
|
|||||||
disabledHint = null,
|
disabledHint = null,
|
||||||
onAction,
|
onAction,
|
||||||
className,
|
className,
|
||||||
|
square,
|
||||||
}: WorkspaceRuntimeControlsProps) {
|
}: WorkspaceRuntimeControlsProps) {
|
||||||
const resolvedSections = sections ?? {
|
const resolvedSections = sections ?? {
|
||||||
services: (items ?? []).map((item) => ({
|
services: (items ?? []).map((item) => ({
|
||||||
@@ -370,14 +381,14 @@ export function WorkspaceRuntimeControls({
|
|||||||
otherServices: [],
|
otherServices: [],
|
||||||
};
|
};
|
||||||
const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage;
|
const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage;
|
||||||
const runningCount = resolvedSections.services.filter(
|
const runningCount = [...resolvedSections.services, ...resolvedSections.otherServices].filter(
|
||||||
(item) => item.statusLabel === "running" || item.statusLabel === "starting",
|
(item) => item.statusLabel === "running" || item.statusLabel === "starting",
|
||||||
).length;
|
).length;
|
||||||
const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint;
|
const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="rounded-xl border border-border/70 bg-background/60 p-3">
|
<div className={cn("border border-border/70 bg-background p-3", square ? "rounded-none" : "rounded-xl")}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -411,6 +422,7 @@ export function WorkspaceRuntimeControls({
|
|||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
pendingRequest={pendingRequest}
|
pendingRequest={pendingRequest}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
square={square}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandSection
|
<CommandSection
|
||||||
@@ -421,6 +433,7 @@ export function WorkspaceRuntimeControls({
|
|||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
pendingRequest={pendingRequest}
|
pendingRequest={pendingRequest}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
square={square}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{resolvedSections.otherServices.length > 0 ? (
|
{resolvedSections.otherServices.length > 0 ? (
|
||||||
@@ -432,6 +445,7 @@ export function WorkspaceRuntimeControls({
|
|||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
pendingRequest={pendingRequest}
|
pendingRequest={pendingRequest}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
square={square}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||||||
"org",
|
"org",
|
||||||
"agents",
|
"agents",
|
||||||
"projects",
|
"projects",
|
||||||
|
"workspaces",
|
||||||
"execution-workspaces",
|
"execution-workspaces",
|
||||||
"issues",
|
"issues",
|
||||||
"routines",
|
"routines",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||||
import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab";
|
import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab";
|
||||||
|
|
||||||
function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||||
@@ -96,6 +96,39 @@ function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace>): Execu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "service-1",
|
||||||
|
companyId: overrides.companyId ?? "company-1",
|
||||||
|
projectId: overrides.projectId ?? "project-1",
|
||||||
|
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||||
|
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||||
|
issueId: overrides.issueId ?? null,
|
||||||
|
scopeType: overrides.scopeType ?? "execution_workspace",
|
||||||
|
scopeId: overrides.scopeId ?? null,
|
||||||
|
serviceName: overrides.serviceName ?? "preview",
|
||||||
|
status: overrides.status ?? "running",
|
||||||
|
lifecycle: overrides.lifecycle ?? "ephemeral",
|
||||||
|
reuseKey: overrides.reuseKey ?? null,
|
||||||
|
command: overrides.command ?? null,
|
||||||
|
cwd: overrides.cwd ?? null,
|
||||||
|
port: overrides.port ?? 3100,
|
||||||
|
url: overrides.url ?? "http://127.0.0.1:3100",
|
||||||
|
provider: overrides.provider ?? "local_process",
|
||||||
|
providerRef: overrides.providerRef ?? null,
|
||||||
|
ownerAgentId: overrides.ownerAgentId ?? null,
|
||||||
|
startedByRunId: overrides.startedByRunId ?? null,
|
||||||
|
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"),
|
||||||
|
startedAt: overrides.startedAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||||
|
stoppedAt: overrides.stoppedAt ?? null,
|
||||||
|
stopPolicy: overrides.stopPolicy ?? null,
|
||||||
|
healthStatus: overrides.healthStatus ?? "healthy",
|
||||||
|
configIndex: overrides.configIndex ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||||
|
updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildProjectWorkspaceSummaries", () => {
|
describe("buildProjectWorkspaceSummaries", () => {
|
||||||
const primaryWorkspace = createProjectWorkspace({
|
const primaryWorkspace = createProjectWorkspace({
|
||||||
id: "workspace-default",
|
id: "workspace-default",
|
||||||
@@ -228,4 +261,63 @@ describe("buildProjectWorkspaceSummaries", () => {
|
|||||||
expect(summaries).toHaveLength(1);
|
expect(summaries).toHaveLength(1);
|
||||||
expect(summaries[0]?.key).toBe("project:workspace-default");
|
expect(summaries[0]?.key).toBe("project:workspace-default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts workspaces with running services first and marks live service urls", () => {
|
||||||
|
const summaries = buildProjectWorkspaceSummaries({
|
||||||
|
project,
|
||||||
|
issues: [
|
||||||
|
createIssue({
|
||||||
|
id: "issue-stopped",
|
||||||
|
executionWorkspaceId: "exec-stopped",
|
||||||
|
updatedAt: new Date("2026-03-27T12:00:00Z"),
|
||||||
|
}),
|
||||||
|
createIssue({
|
||||||
|
id: "issue-live",
|
||||||
|
executionWorkspaceId: "exec-live",
|
||||||
|
updatedAt: new Date("2026-03-25T12:00:00Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
executionWorkspaces: [
|
||||||
|
createExecutionWorkspace({
|
||||||
|
id: "exec-stopped",
|
||||||
|
name: "newer stopped",
|
||||||
|
lastUsedAt: new Date("2026-03-27T12:00:00Z"),
|
||||||
|
runtimeServices: [
|
||||||
|
createRuntimeService({
|
||||||
|
id: "service-stopped",
|
||||||
|
executionWorkspaceId: "exec-stopped",
|
||||||
|
status: "stopped",
|
||||||
|
url: "http://127.0.0.1:4100",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createExecutionWorkspace({
|
||||||
|
id: "exec-live",
|
||||||
|
name: "older live",
|
||||||
|
lastUsedAt: new Date("2026-03-25T12:00:00Z"),
|
||||||
|
runtimeServices: [
|
||||||
|
createRuntimeService({
|
||||||
|
id: "service-live",
|
||||||
|
executionWorkspaceId: "exec-live",
|
||||||
|
status: "running",
|
||||||
|
url: "http://127.0.0.1:4200",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summaries[0]).toMatchObject({
|
||||||
|
key: "execution:exec-live",
|
||||||
|
primaryServiceUrl: "http://127.0.0.1:4200",
|
||||||
|
primaryServiceUrlRunning: true,
|
||||||
|
runningServiceCount: 1,
|
||||||
|
});
|
||||||
|
expect(summaries[1]).toMatchObject({
|
||||||
|
key: "execution:exec-stopped",
|
||||||
|
primaryServiceUrl: "http://127.0.0.1:4100",
|
||||||
|
primaryServiceUrlRunning: false,
|
||||||
|
runningServiceCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ProjectWorkspaceSummary {
|
|||||||
serviceCount: number;
|
serviceCount: number;
|
||||||
runningServiceCount: number;
|
runningServiceCount: number;
|
||||||
primaryServiceUrl: string | null;
|
primaryServiceUrl: string | null;
|
||||||
|
primaryServiceUrlRunning: boolean;
|
||||||
hasRuntimeConfig: boolean;
|
hasRuntimeConfig: boolean;
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,24 @@ function isDefaultSharedExecutionWorkspace(input: {
|
|||||||
return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId;
|
return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeServiceSummary(
|
||||||
|
services: NonNullable<ExecutionWorkspace["runtimeServices"]> | undefined,
|
||||||
|
) {
|
||||||
|
const serviceCount = services?.length ?? 0;
|
||||||
|
const runningServiceCount = services?.filter((service) => service.status === "running").length ?? 0;
|
||||||
|
const primaryService =
|
||||||
|
services?.find((service) => service.status === "running" && service.url)
|
||||||
|
?? services?.find((service) => service.url)
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceCount,
|
||||||
|
runningServiceCount,
|
||||||
|
primaryServiceUrl: primaryService?.url ?? null,
|
||||||
|
primaryServiceUrlRunning: primaryService?.status === "running",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProjectWorkspaceSummaries(input: {
|
export function buildProjectWorkspaceSummaries(input: {
|
||||||
project: ProjectWorkspaceLike;
|
project: ProjectWorkspaceLike;
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
@@ -81,6 +100,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
);
|
);
|
||||||
|
const runtimeSummary = runtimeServiceSummary(executionWorkspace.runtimeServices);
|
||||||
|
|
||||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||||
key: `execution:${executionWorkspace.id}`,
|
key: `execution:${executionWorkspace.id}`,
|
||||||
@@ -98,9 +118,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||||
executionWorkspaceId: executionWorkspace.id,
|
executionWorkspaceId: executionWorkspace.id,
|
||||||
executionWorkspaceStatus: executionWorkspace.status,
|
executionWorkspaceStatus: executionWorkspace.status,
|
||||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
...runtimeSummary,
|
||||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
|
||||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
|
||||||
hasRuntimeConfig: Boolean(
|
hasRuntimeConfig: Boolean(
|
||||||
executionWorkspace.config?.workspaceRuntime
|
executionWorkspace.config?.workspaceRuntime
|
||||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||||
@@ -118,6 +136,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
);
|
);
|
||||||
|
const runtimeSummary = runtimeServiceSummary(projectWorkspace.runtimeServices);
|
||||||
|
|
||||||
summaries.set(`project:${projectWorkspace.id}`, {
|
summaries.set(`project:${projectWorkspace.id}`, {
|
||||||
key: `project:${projectWorkspace.id}`,
|
key: `project:${projectWorkspace.id}`,
|
||||||
@@ -130,9 +149,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
projectWorkspaceId: projectWorkspace.id,
|
projectWorkspaceId: projectWorkspace.id,
|
||||||
executionWorkspaceId: null,
|
executionWorkspaceId: null,
|
||||||
executionWorkspaceStatus: null,
|
executionWorkspaceStatus: null,
|
||||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
...runtimeSummary,
|
||||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
|
||||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
|
||||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||||
issues: nextIssues,
|
issues: nextIssues,
|
||||||
});
|
});
|
||||||
@@ -146,6 +163,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|
||||||
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
|
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
|
||||||
if (!shouldSurfaceWorkspace) continue;
|
if (!shouldSurfaceWorkspace) continue;
|
||||||
|
const runtimeSummary = runtimeServiceSummary(projectWorkspace.runtimeServices);
|
||||||
summaries.set(key, {
|
summaries.set(key, {
|
||||||
key,
|
key,
|
||||||
kind: "project_workspace",
|
kind: "project_workspace",
|
||||||
@@ -157,15 +175,15 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||||||
projectWorkspaceId: projectWorkspace.id,
|
projectWorkspaceId: projectWorkspace.id,
|
||||||
executionWorkspaceId: null,
|
executionWorkspaceId: null,
|
||||||
executionWorkspaceStatus: null,
|
executionWorkspaceStatus: null,
|
||||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
...runtimeSummary,
|
||||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
|
||||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
|
||||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||||
issues: [],
|
issues: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...summaries.values()].sort((a, b) => {
|
return [...summaries.values()].sort((a, b) => {
|
||||||
|
const liveDiff = Number(b.runningServiceCount > 0) - Number(a.runningServiceCount > 0);
|
||||||
|
if (liveDiff !== 0) return liveDiff;
|
||||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CopyText } from "../components/CopyText";
|
import { CopyText } from "../components/CopyText";
|
||||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -188,10 +191,10 @@ function Field({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="space-y-1.5">
|
<label className="block space-y-2">
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
||||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
{hint ? <span className="text-xs text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
@@ -532,22 +535,19 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<Card>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader>
|
||||||
<div className="space-y-1">
|
<CardTitle>Services and jobs</CardTitle>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
|
<CardDescription>
|
||||||
<h2 className="text-lg font-semibold">Services and jobs</h2>
|
Source: {runtimeConfigSource === "execution_workspace"
|
||||||
<p className="text-sm text-muted-foreground">
|
? "execution workspace override"
|
||||||
Source: {runtimeConfigSource === "execution_workspace"
|
: runtimeConfigSource === "project_workspace"
|
||||||
? "execution workspace override"
|
? "project workspace default"
|
||||||
: runtimeConfigSource === "project_workspace"
|
: "none"}
|
||||||
? "project workspace default"
|
</CardDescription>
|
||||||
: "none"}
|
</CardHeader>
|
||||||
</p>
|
<CardContent>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<WorkspaceRuntimeControls
|
<WorkspaceRuntimeControls
|
||||||
className="mt-4"
|
|
||||||
sections={runtimeControlSections}
|
sections={runtimeControlSections}
|
||||||
isPending={controlRuntimeServices.isPending}
|
isPending={controlRuntimeServices.isPending}
|
||||||
pendingRequest={pendingRuntimeAction}
|
pendingRequest={pendingRuntimeAction}
|
||||||
@@ -566,7 +566,8 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
/>
|
/>
|
||||||
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
||||||
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
@@ -583,181 +584,203 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
|
|
||||||
{activeTab === "configuration" ? (
|
{activeTab === "configuration" ? (
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<Card>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<CardHeader>
|
||||||
<div className="space-y-1">
|
<CardTitle>Workspace settings</CardTitle>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
<CardDescription>
|
||||||
Configuration
|
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||||
</div>
|
</CardDescription>
|
||||||
<h2 className="text-lg font-semibold">Workspace settings</h2>
|
<CardAction>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button
|
||||||
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
variant="destructive"
|
||||||
</p>
|
size="sm"
|
||||||
</div>
|
className="w-full sm:w-auto"
|
||||||
<Button
|
onClick={() => setCloseDialogOpen(true)}
|
||||||
variant="outline"
|
disabled={workspace.status === "archived"}
|
||||||
className="w-full sm:w-auto"
|
>
|
||||||
onClick={() => setCloseDialogOpen(true)}
|
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||||
disabled={workspace.status === "archived"}
|
</Button>
|
||||||
>
|
</CardAction>
|
||||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
</CardHeader>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-5" />
|
<CardContent>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<Field label="Workspace name">
|
<div className="space-y-4">
|
||||||
<input
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">General</div>
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
<Field label="Workspace name">
|
||||||
value={form.name}
|
<Input
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
value={form.name}
|
||||||
placeholder="Execution workspace name"
|
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||||
/>
|
placeholder="Execution workspace name"
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
|
||||||
value={form.branchName}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
|
||||||
placeholder="PAP-946-workspace"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Working directory">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
|
||||||
value={form.cwd}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
|
||||||
placeholder="/absolute/path/to/workspace"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Provider path / ref">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
|
||||||
value={form.providerRef}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
|
||||||
placeholder="/path/to/worktree or provider ref"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Repo URL">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
|
||||||
value={form.repoUrl}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
|
||||||
placeholder="https://github.com/org/repo"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Base ref">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
|
||||||
value={form.baseRef}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
|
||||||
placeholder="origin/main"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
|
||||||
value={form.provisionCommand}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
|
||||||
placeholder="bash ./scripts/provision-worktree.sh"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
|
||||||
value={form.teardownCommand}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
|
||||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
|
||||||
<textarea
|
|
||||||
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
|
||||||
value={form.cleanupCommand}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
|
||||||
placeholder="pkill -f vite || true"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
|
||||||
Runtime config source
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{runtimeConfigSource === "execution_workspace"
|
|
||||||
? "This execution workspace currently overrides the project workspace runtime config."
|
|
||||||
: runtimeConfigSource === "project_workspace"
|
|
||||||
? "This execution workspace is inheriting the project workspace runtime config."
|
|
||||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
size="sm"
|
|
||||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
|
||||||
onClick={() =>
|
|
||||||
setForm((current) => current ? {
|
|
||||||
...current,
|
|
||||||
inheritRuntime: true,
|
|
||||||
workspaceRuntime: "",
|
|
||||||
} : current)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Reset to inherit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
<Separator />
|
||||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<div className="space-y-4">
|
||||||
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Source control</div>
|
||||||
</p>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="mt-3">
|
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||||
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs.">
|
<Input
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
className="font-mono"
|
||||||
<input
|
value={form.branchName}
|
||||||
id="inherit-runtime-config"
|
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||||
type="checkbox"
|
placeholder="PAP-946-workspace"
|
||||||
checked={form.inheritRuntime}
|
/>
|
||||||
onChange={(event) => {
|
</Field>
|
||||||
const checked = event.target.checked;
|
|
||||||
setForm((current) => {
|
<Field label="Base ref">
|
||||||
if (!current) return current;
|
<Input
|
||||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
className="font-mono"
|
||||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
value={form.baseRef}
|
||||||
}
|
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||||
return { ...current, inheritRuntime: checked };
|
placeholder="origin/main"
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="min-h-64 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-96"
|
|
||||||
value={form.workspaceRuntime}
|
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
|
||||||
disabled={form.inheritRuntime}
|
|
||||||
placeholder={'{\n "commands": [\n {\n "id": "web",\n "name": "web",\n "kind": "service",\n "command": "pnpm dev",\n "cwd": ".",\n "port": { "type": "auto" }\n },\n {\n "id": "db-migrate",\n "name": "db:migrate",\n "kind": "job",\n "command": "pnpm db:migrate",\n "cwd": "."\n }\n ]\n}'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
|
<Field label="Repo URL">
|
||||||
|
<Input
|
||||||
|
value={form.repoUrl}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||||
|
placeholder="https://github.com/org/repo"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Paths</div>
|
||||||
|
<Field label="Working directory">
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
value={form.cwd}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||||
|
placeholder="/absolute/path/to/workspace"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Provider path / ref">
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
value={form.providerRef}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||||
|
placeholder="/path/to/worktree or provider ref"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Lifecycle commands</div>
|
||||||
|
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-20 font-mono"
|
||||||
|
value={form.provisionCommand}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||||
|
placeholder="bash ./scripts/provision-worktree.sh"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-20 font-mono"
|
||||||
|
value={form.teardownCommand}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||||
|
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-16 font-mono"
|
||||||
|
value={form.cleanupCommand}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||||
|
placeholder="pkill -f vite || true"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
Runtime config source
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{runtimeConfigSource === "execution_workspace"
|
||||||
|
? "This execution workspace currently overrides the project workspace runtime config."
|
||||||
|
: runtimeConfigSource === "project_workspace"
|
||||||
|
? "This execution workspace is inheriting the project workspace runtime config."
|
||||||
|
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
size="sm"
|
||||||
|
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((current) => current ? {
|
||||||
|
...current,
|
||||||
|
inheritRuntime: true,
|
||||||
|
workspaceRuntime: "",
|
||||||
|
} : current)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset to inherit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs.">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<input
|
||||||
|
id="inherit-runtime-config"
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-border"
|
||||||
|
checked={form.inheritRuntime}
|
||||||
|
onChange={(event) => {
|
||||||
|
const checked = event.target.checked;
|
||||||
|
setForm((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||||
|
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||||
|
}
|
||||||
|
return { ...current, inheritRuntime: checked };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-64 font-mono sm:min-h-96"
|
||||||
|
value={form.workspaceRuntime}
|
||||||
|
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||||
|
disabled={form.inheritRuntime}
|
||||||
|
placeholder={'{\n "commands": [\n {\n "id": "web",\n "name": "web",\n "kind": "service",\n "command": "pnpm dev",\n "cwd": ".",\n "port": { "type": "auto" }\n },\n {\n "id": "db-migrate",\n "name": "db:migrate",\n "kind": "job",\n "command": "pnpm db:migrate",\n "cwd": "."\n }\n ]\n}'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
Save changes
|
Save changes
|
||||||
@@ -778,14 +801,15 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<Card>
|
||||||
<div className="space-y-1">
|
<CardHeader>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
<CardTitle>Workspace context</CardTitle>
|
||||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
<CardDescription>Linked objects and relationships</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Separator className="my-4" />
|
<CardContent>
|
||||||
<DetailRow label="Project">
|
<DetailRow label="Project">
|
||||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
@@ -823,14 +847,15 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
<DetailRow label="Workspace ID">
|
<DetailRow label="Workspace ID">
|
||||||
<MonoValue value={workspace.id} />
|
<MonoValue value={workspace.id} />
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<Card>
|
||||||
<div className="space-y-1">
|
<CardHeader>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
<CardTitle>Concrete location</CardTitle>
|
||||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
<CardDescription>Paths and refs</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Separator className="my-4" />
|
<CardContent>
|
||||||
<DetailRow label="Working dir">
|
<DetailRow label="Working dir">
|
||||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
@@ -867,15 +892,16 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||||
: "Not scheduled"}
|
: "Not scheduled"}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === "runtime_logs" ? (
|
) : activeTab === "runtime_logs" ? (
|
||||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<Card>
|
||||||
<div className="space-y-1">
|
<CardHeader>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
<CardTitle>Runtime and cleanup logs</CardTitle>
|
||||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
<CardDescription>Recent operations</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Separator className="my-4" />
|
<CardContent>
|
||||||
{workspaceOperationsQuery.isLoading ? (
|
{workspaceOperationsQuery.isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||||
) : workspaceOperationsQuery.error ? (
|
) : workspaceOperationsQuery.error ? (
|
||||||
@@ -887,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{workspaceOperationsQuery.data.map((operation) => (
|
{workspaceOperationsQuery.data.map((operation) => (
|
||||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
<div key={operation.id} className="rounded-md border border-border/80 bg-muted/30 px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||||
@@ -909,7 +935,8 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<ExecutionWorkspaceIssuesList
|
<ExecutionWorkspaceIssuesList
|
||||||
companyId={workspace.companyId}
|
companyId={workspace.companyId}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared";
|
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
@@ -19,18 +19,16 @@ import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveSta
|
|||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { ProjectWorkspaceSummaryCard } from "../components/ProjectWorkspaceSummaryCard";
|
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||||
import { projectRouteRef } from "../lib/utils";
|
import { projectRouteRef } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
@@ -215,110 +213,6 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectWorkspacesContent({
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
projectRef,
|
|
||||||
summaries,
|
|
||||||
}: {
|
|
||||||
companyId: string;
|
|
||||||
projectId: string;
|
|
||||||
projectRef: string;
|
|
||||||
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
|
||||||
}) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
|
||||||
const [closingWorkspace, setClosingWorkspace] = useState<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: ExecutionWorkspace["status"];
|
|
||||||
} | null>(null);
|
|
||||||
const controlWorkspaceRuntime = useMutation({
|
|
||||||
mutationFn: async (input: {
|
|
||||||
key: string;
|
|
||||||
kind: "project_workspace" | "execution_workspace";
|
|
||||||
workspaceId: string;
|
|
||||||
action: "start" | "stop" | "restart";
|
|
||||||
}) => {
|
|
||||||
setRuntimeActionKey(`${input.key}:${input.action}`);
|
|
||||||
if (input.kind === "project_workspace") {
|
|
||||||
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
|
||||||
}
|
|
||||||
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setRuntimeActionKey(null);
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (summaries.length === 0) {
|
|
||||||
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
|
||||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
||||||
{activeSummaries.map((summary) => (
|
|
||||||
<ProjectWorkspaceSummaryCard
|
|
||||||
key={summary.key}
|
|
||||||
projectRef={projectRef}
|
|
||||||
summary={summary}
|
|
||||||
runtimeActionKey={runtimeActionKey}
|
|
||||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
|
||||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
|
||||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{cleanupFailedSummaries.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
Cleanup attention needed
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
|
|
||||||
{cleanupFailedSummaries.map((summary) => (
|
|
||||||
<ProjectWorkspaceSummaryCard
|
|
||||||
key={summary.key}
|
|
||||||
projectRef={projectRef}
|
|
||||||
summary={summary}
|
|
||||||
runtimeActionKey={runtimeActionKey}
|
|
||||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
|
||||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
|
||||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{closingWorkspace ? (
|
|
||||||
<ExecutionWorkspaceCloseDialog
|
|
||||||
workspaceId={closingWorkspace.id}
|
|
||||||
workspaceName={closingWorkspace.name}
|
|
||||||
currentStatus={closingWorkspace.status}
|
|
||||||
open
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setClosingWorkspace(null);
|
|
||||||
}}
|
|
||||||
onClosed={() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
|
||||||
setClosingWorkspace(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main project page ── */
|
/* ── Main project page ── */
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
|
|||||||
163
ui/src/pages/Workspaces.tsx
Normal file
163
ui/src/pages/Workspaces.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Link, Navigate } from "@/lib/router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||||
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { buildProjectWorkspaceSummaries, type ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { projectRouteRef } from "../lib/utils";
|
||||||
|
|
||||||
|
type ProjectWorkspaceGroup = {
|
||||||
|
project: Project;
|
||||||
|
projectRef: string;
|
||||||
|
summaries: ProjectWorkspaceSummary[];
|
||||||
|
lastUpdatedAt: Date;
|
||||||
|
runningServiceCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildProjectWorkspaceGroups(input: {
|
||||||
|
projects: Project[];
|
||||||
|
issues: Issue[];
|
||||||
|
executionWorkspaces: ExecutionWorkspace[];
|
||||||
|
}): ProjectWorkspaceGroup[] {
|
||||||
|
const issuesByProjectId = new Map<string, Issue[]>();
|
||||||
|
for (const issue of input.issues) {
|
||||||
|
if (!issue.projectId) continue;
|
||||||
|
const existing = issuesByProjectId.get(issue.projectId) ?? [];
|
||||||
|
existing.push(issue);
|
||||||
|
issuesByProjectId.set(issue.projectId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionWorkspacesByProjectId = new Map<string, ExecutionWorkspace[]>();
|
||||||
|
for (const workspace of input.executionWorkspaces) {
|
||||||
|
if (!workspace.projectId) continue;
|
||||||
|
const existing = executionWorkspacesByProjectId.get(workspace.projectId) ?? [];
|
||||||
|
existing.push(workspace);
|
||||||
|
executionWorkspacesByProjectId.set(workspace.projectId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.projects
|
||||||
|
.map((project) => {
|
||||||
|
const summaries = buildProjectWorkspaceSummaries({
|
||||||
|
project,
|
||||||
|
issues: issuesByProjectId.get(project.id) ?? [],
|
||||||
|
executionWorkspaces: executionWorkspacesByProjectId.get(project.id) ?? [],
|
||||||
|
});
|
||||||
|
if (summaries.length === 0) return null;
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
projectRef: projectRouteRef(project),
|
||||||
|
summaries,
|
||||||
|
lastUpdatedAt: summaries.reduce(
|
||||||
|
(latest, summary) => summary.lastUpdatedAt.getTime() > latest.getTime() ? summary.lastUpdatedAt : latest,
|
||||||
|
new Date(0),
|
||||||
|
),
|
||||||
|
runningServiceCount: summaries.reduce((count, summary) => count + summary.runningServiceCount, 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((group): group is ProjectWorkspaceGroup => group !== null)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const runningDiff = b.runningServiceCount - a.runningServiceCount;
|
||||||
|
if (runningDiff !== 0) return runningDiff;
|
||||||
|
const updatedDiff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||||
|
return updatedDiff !== 0 ? updatedDiff : a.project.name.localeCompare(b.project.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Workspaces() {
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const experimentalSettingsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
|
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
|
|
||||||
|
const { data: projects = [], isLoading: projectsLoading, error: projectsError } = useQuery({
|
||||||
|
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "__workspaces__", "disabled"],
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||||
|
});
|
||||||
|
const { data: issues = [], isLoading: issuesLoading, error: issuesError } = useQuery({
|
||||||
|
queryKey: selectedCompanyId ? queryKeys.issues.list(selectedCompanyId) : ["issues", "__workspaces__", "disabled"],
|
||||||
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: executionWorkspaces = [],
|
||||||
|
isLoading: executionWorkspacesLoading,
|
||||||
|
error: executionWorkspacesError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||||
|
: ["execution-workspaces", "__workspaces__", "disabled"],
|
||||||
|
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([{ label: "Workspaces" }]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() => buildProjectWorkspaceGroups({ projects, issues, executionWorkspaces }),
|
||||||
|
[executionWorkspaces, issues, projects],
|
||||||
|
);
|
||||||
|
const dataLoading = projectsLoading || issuesLoading || executionWorkspacesLoading;
|
||||||
|
const error = (projectsError ?? issuesError ?? executionWorkspacesError) as Error | null;
|
||||||
|
|
||||||
|
if (experimentalSettingsQuery.isLoading) return <PageSkeleton variant="detail" />;
|
||||||
|
if (!isolatedWorkspacesEnabled) return <Navigate to="/issues" replace />;
|
||||||
|
if (dataLoading) return <PageSkeleton variant="list" />;
|
||||||
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">Workspaces</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No workspace activity yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.project.id} className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
to={`/projects/${group.projectRef}/workspaces`}
|
||||||
|
className="text-base font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{group.project.name}
|
||||||
|
</Link>
|
||||||
|
{group.project.description ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{group.project.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProjectWorkspacesContent
|
||||||
|
companyId={selectedCompanyId!}
|
||||||
|
projectId={group.project.id}
|
||||||
|
projectRef={group.projectRef}
|
||||||
|
summaries={group.summaries}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user