forked from farhoodlabs/paperclip
[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:
@@ -1,21 +1,34 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CopyTextProps {
|
||||
text: string;
|
||||
/** What to display. Defaults to `text`. */
|
||||
children?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
title?: string;
|
||||
/** Tooltip message shown after copying. Default: "Copied!" */
|
||||
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 [label, setLabel] = useState(copiedLabel);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -45,10 +58,12 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }:
|
||||
}, [copiedLabel, text]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<span className={cn("relative inline-flex", containerClassName)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
className={cn(
|
||||
"cursor-copy hover:text-foreground transition-colors",
|
||||
className,
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
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 type { Issue } from "@paperclipai/shared";
|
||||
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 {
|
||||
return {
|
||||
mode: "normal",
|
||||
@@ -229,6 +361,59 @@ describe("IssueProperties", () => {
|
||||
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 () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
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 { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -72,6 +72,35 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||
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 {
|
||||
issue: Issue;
|
||||
childIssues?: Issue[];
|
||||
@@ -253,6 +282,11 @@ export function IssueProperties({
|
||||
const currentProject = issue.projectId
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? 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) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
@@ -1117,10 +1151,23 @@ export function IssueProperties({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{liveWorkspaceService?.url && (
|
||||
<PropertyRow label="Service">
|
||||
<a
|
||||
href={liveWorkspaceService.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-start gap-1 text-sm font-mono text-emerald-700 hover:text-emerald-800 hover:underline dark:text-emerald-300 dark:hover:text-emerald-200"
|
||||
>
|
||||
<span className="min-w-0 break-all">{liveWorkspaceService.url}</span>
|
||||
<ExternalLink className="mt-1 h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.executionWorkspaceId && (
|
||||
<PropertyRow label="Workspace">
|
||||
<Link
|
||||
|
||||
@@ -16,10 +16,6 @@ vi.mock("./IssuesQuicklook", () => ({
|
||||
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("./CopyText", () => ({
|
||||
CopyText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -75,6 +71,7 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
||||
serviceCount: overrides.serviceCount ?? 2,
|
||||
runningServiceCount: overrides.runningServiceCount ?? 0,
|
||||
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474",
|
||||
primaryServiceUrlRunning: overrides.primaryServiceUrlRunning ?? false,
|
||||
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
||||
issues: overrides.issues ?? [
|
||||
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
||||
@@ -88,10 +85,20 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
||||
|
||||
describe("ProjectWorkspaceSummaryCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let writeClipboard: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
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(() => {
|
||||
@@ -124,6 +131,9 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
||||
|
||||
const actions = container.querySelector('[data-testid="workspace-summary-actions"]');
|
||||
expect(actions?.className).toContain("flex-col");
|
||||
const card = container.firstElementChild;
|
||||
expect(card?.className).toContain("rounded-lg");
|
||||
expect(card?.className).toContain("border");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
@@ -189,4 +199,87 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("copies branch and path from both text and icon controls with feedback", async () => {
|
||||
const root = createRoot(container);
|
||||
const summary = createSummary({
|
||||
branchName: "PAP-1552-workspace-polish",
|
||||
cwd: "/Users/dotta/paperclip/.worktrees/PAP-1552-workspace-polish",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={summary}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={() => {}}
|
||||
onCloseWorkspace={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const branchTextButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === summary.branchName);
|
||||
const pathTextButton = container.querySelector(`button[title="${summary.cwd}"]`);
|
||||
const branchIconButton = container.querySelector('button[aria-label="Copy branch"]');
|
||||
const pathIconButton = container.querySelector('button[aria-label="Copy path"]');
|
||||
|
||||
expect(branchTextButton).not.toBeNull();
|
||||
expect(pathTextButton).not.toBeNull();
|
||||
expect(branchIconButton).not.toBeNull();
|
||||
expect(pathIconButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
branchTextButton!.click();
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.branchName);
|
||||
expect(branchTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
||||
await act(async () => {
|
||||
pathTextButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.cwd);
|
||||
expect(pathTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
||||
await act(async () => {
|
||||
branchIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
pathIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.branchName);
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.cwd);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("colors live service urls green", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={createSummary({
|
||||
primaryServiceUrl: "http://127.0.0.1:62475",
|
||||
primaryServiceUrlRunning: true,
|
||||
runningServiceCount: 1,
|
||||
})}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={() => {}}
|
||||
onCloseWorkspace={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const serviceLink = container.querySelector("a[href='http://127.0.0.1:62475']");
|
||||
expect(serviceLink?.className).toContain("text-emerald");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ProjectWorkspaceSummaryCard({
|
||||
const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`;
|
||||
|
||||
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-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
@@ -143,14 +143,31 @@ export function ProjectWorkspaceSummaryCard({
|
||||
</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">
|
||||
{summary.branchName ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<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="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>
|
||||
) : null}
|
||||
@@ -161,10 +178,21 @@ export function ProjectWorkspaceSummaryCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Path</div>
|
||||
<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)}
|
||||
</span>
|
||||
<CopyText text={summary.cwd} className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Path copied">
|
||||
</CopyText>
|
||||
<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" />
|
||||
</CopyText>
|
||||
</div>
|
||||
@@ -181,7 +209,12 @@ export function ProjectWorkspaceSummaryCard({
|
||||
href={summary.primaryServiceUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
liveRunsForCompany: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
NavLink: ({ to, children, className, ...props }: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
className?: string | ((state: { isActive: boolean }) => string);
|
||||
}) => (
|
||||
<a
|
||||
href={to}
|
||||
className={typeof className === "function" ? className({ isActive: false }) : className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../api/heartbeats", () => ({
|
||||
heartbeatsApi: mockHeartbeatsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useInboxBadge", () => ({
|
||||
useInboxBadge: () => ({ inbox: 0, failedRuns: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: () => null,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Workspaces");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Workspaces link when isolated workspaces are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
|
||||
expect(link?.getAttribute("href")).toBe("/workspaces");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Network,
|
||||
Boxes,
|
||||
Repeat,
|
||||
GitBranch,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -20,6 +21,7 @@ import { SidebarAgents } from "./SidebarAgents";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -30,6 +32,10 @@ export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||
@@ -37,6 +43,7 @@ export function Sidebar() {
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
|
||||
function openSearch() {
|
||||
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="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
||||
@@ -76,6 +76,74 @@ describe("buildWorkspaceRuntimeControlSections", () => {
|
||||
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", () => {
|
||||
@@ -237,6 +305,42 @@ describe("WorkspaceRuntimeControls", () => {
|
||||
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", () => {
|
||||
const items = buildWorkspaceRuntimeControlItems({
|
||||
runtimeConfig: {
|
||||
|
||||
@@ -57,6 +57,7 @@ type WorkspaceRuntimeControlsProps = {
|
||||
disabledHint?: string | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
className?: string;
|
||||
square?: boolean;
|
||||
} | {
|
||||
sections?: never;
|
||||
items: LegacyWorkspaceRuntimeControlItem[];
|
||||
@@ -68,6 +69,7 @@ type WorkspaceRuntimeControlsProps = {
|
||||
disabledHint?: string | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
className?: string;
|
||||
square?: boolean;
|
||||
};
|
||||
|
||||
export function hasRunningRuntimeServices(
|
||||
@@ -149,7 +151,9 @@ export function buildWorkspaceRuntimeControlSections(input: {
|
||||
}
|
||||
|
||||
const otherServices = runtimeServices
|
||||
.filter((runtimeService) => !matchedRuntimeServiceIds.has(runtimeService.id))
|
||||
.filter((runtimeService) =>
|
||||
!matchedRuntimeServiceIds.has(runtimeService.id)
|
||||
&& (runtimeService.status === "starting" || runtimeService.status === "running"))
|
||||
.map((runtimeService) => ({
|
||||
key: `runtime:${runtimeService.id}`,
|
||||
title: runtimeService.serviceName,
|
||||
@@ -212,11 +216,13 @@ function CommandActionButtons({
|
||||
isPending,
|
||||
pendingRequest,
|
||||
onAction,
|
||||
square,
|
||||
}: {
|
||||
item: WorkspaceRuntimeControlItem;
|
||||
isPending: boolean;
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
}) {
|
||||
const actions: WorkspaceRuntimeAction[] =
|
||||
item.kind === "job"
|
||||
@@ -249,7 +255,8 @@ function CommandActionButtons({
|
||||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||
size="sm"
|
||||
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,
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -273,6 +280,7 @@ function CommandSection({
|
||||
isPending,
|
||||
pendingRequest,
|
||||
onAction,
|
||||
square,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -282,6 +290,7 @@ function CommandSection({
|
||||
isPending: boolean;
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -290,14 +299,14 @@ function CommandSection({
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{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}
|
||||
{disabledHint ? <p className="mt-2 text-xs">{disabledHint}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
@@ -312,6 +321,7 @@ function CommandSection({
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
@@ -360,6 +370,7 @@ export function WorkspaceRuntimeControls({
|
||||
disabledHint = null,
|
||||
onAction,
|
||||
className,
|
||||
square,
|
||||
}: WorkspaceRuntimeControlsProps) {
|
||||
const resolvedSections = sections ?? {
|
||||
services: (items ?? []).map((item) => ({
|
||||
@@ -370,14 +381,14 @@ export function WorkspaceRuntimeControls({
|
||||
otherServices: [],
|
||||
};
|
||||
const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage;
|
||||
const runningCount = resolvedSections.services.filter(
|
||||
const runningCount = [...resolvedSections.services, ...resolvedSections.otherServices].filter(
|
||||
(item) => item.statusLabel === "running" || item.statusLabel === "starting",
|
||||
).length;
|
||||
const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint;
|
||||
|
||||
return (
|
||||
<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="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -411,6 +422,7 @@ export function WorkspaceRuntimeControls({
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
|
||||
<CommandSection
|
||||
@@ -421,6 +433,7 @@ export function WorkspaceRuntimeControls({
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
|
||||
{resolvedSections.otherServices.length > 0 ? (
|
||||
@@ -432,6 +445,7 @@ export function WorkspaceRuntimeControls({
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user