[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:
Dotta 2026-04-20 06:14:32 -05:00 committed by GitHub
parent d8b63a18e7
commit fee514efcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1348 additions and 351 deletions

View file

@ -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();
});
}); });

View file

@ -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;
} }

View file

@ -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 />} />

View file

@ -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,

View file

@ -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({

View file

@ -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

View file

@ -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();
});
});
}); });

View file

@ -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>

View 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}
</>
);
}

View 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();
});
});
});

View file

@ -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 />

View file

@ -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: {

View file

@ -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>

View file

@ -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",

View file

@ -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,
});
});
}); });

View file

@ -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);
}); });

View file

@ -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>
<p className="text-sm text-muted-foreground">
Source: {runtimeConfigSource === "execution_workspace" Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override" ? "execution workspace override"
: runtimeConfigSource === "project_workspace" : runtimeConfigSource === "project_workspace"
? "project workspace default" ? "project workspace default"
: "none"} : "none"}
</p> </CardDescription>
</div> </CardHeader>
</div> <CardContent>
<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,51 +584,79 @@ 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
</div>
<h2 className="text-lg font-semibold">Workspace settings</h2>
<p className="text-sm text-muted-foreground">
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace. Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
</p> </CardDescription>
</div> <CardAction>
<Button <Button
variant="outline" variant="destructive"
size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => setCloseDialogOpen(true)} onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"} disabled={workspace.status === "archived"}
> >
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"} {workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
</Button> </Button>
</div> </CardAction>
</CardHeader>
<Separator className="my-5" /> <CardContent>
<div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">General</div>
<Field label="Workspace name"> <Field label="Workspace name">
<input <Input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.name} value={form.name}
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
placeholder="Execution workspace name" placeholder="Execution workspace name"
/> />
</Field> </Field>
</div>
<Separator />
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Source control</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Branch name" hint="Useful for isolated worktrees"> <Field label="Branch name" hint="Useful for isolated worktrees">
<input <Input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none" className="font-mono"
value={form.branchName} value={form.branchName}
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
placeholder="PAP-946-workspace" placeholder="PAP-946-workspace"
/> />
</Field> </Field>
<Field label="Base ref">
<Input
className="font-mono"
value={form.baseRef}
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
</div>
<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"> <Field label="Working directory">
<input <Input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none" className="font-mono"
value={form.cwd} value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace" placeholder="/absolute/path/to/workspace"
@ -635,35 +664,22 @@ export function ExecutionWorkspaceDetail() {
</Field> </Field>
<Field label="Provider path / ref"> <Field label="Provider path / ref">
<input <Input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none" className="font-mono"
value={form.providerRef} value={form.providerRef}
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
placeholder="/path/to/worktree or provider ref" placeholder="/path/to/worktree or provider ref"
/> />
</Field> </Field>
</div>
<Field label="Repo URL"> <Separator />
<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>
<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"> <Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<textarea <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" className="min-h-20 font-mono"
value={form.provisionCommand} value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh" placeholder="bash ./scripts/provision-worktree.sh"
@ -671,8 +687,8 @@ export function ExecutionWorkspaceDetail() {
</Field> </Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up"> <Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<textarea <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" className="min-h-20 font-mono"
value={form.teardownCommand} value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh" placeholder="bash ./scripts/teardown-worktree.sh"
@ -680,21 +696,26 @@ export function ExecutionWorkspaceDetail() {
</Field> </Field>
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown"> <Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<textarea <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" className="min-h-16 font-mono"
value={form.cleanupCommand} value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true" placeholder="pkill -f vite || true"
/> />
</Field> </Field>
</div>
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3"> <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="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div> <div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground"> <div className="text-sm font-medium text-foreground">
Runtime config source Runtime config source
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{runtimeConfigSource === "execution_workspace" {runtimeConfigSource === "execution_workspace"
? "This execution workspace currently overrides the project workspace runtime config." ? "This execution workspace currently overrides the project workspace runtime config."
: runtimeConfigSource === "project_workspace" : runtimeConfigSource === "project_workspace"
@ -720,17 +741,18 @@ export function ExecutionWorkspaceDetail() {
</div> </div>
</div> </div>
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3"> <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> <summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground"> <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. Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
</p> </p>
<div className="mt-3"> <div className="mt-3">
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs."> <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-xs text-muted-foreground"> <div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<input <input
id="inherit-runtime-config" id="inherit-runtime-config"
type="checkbox" type="checkbox"
className="rounded border-border"
checked={form.inheritRuntime} checked={form.inheritRuntime}
onChange={(event) => { onChange={(event) => {
const checked = event.target.checked; const checked = event.target.checked;
@ -745,8 +767,8 @@ export function ExecutionWorkspaceDetail() {
/> />
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label> <label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
</div> </div>
<textarea <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" className="min-h-64 font-mono sm:min-h-96"
value={form.workspaceRuntime} value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)} onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
disabled={form.inheritRuntime} disabled={form.inheritRuntime}
@ -756,8 +778,9 @@ export function ExecutionWorkspaceDetail() {
</div> </div>
</details> </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}

View file

@ -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
View 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>
);
}