forked from farhoodlabs/paperclip
[codex] Improve workspace runtime and navigation ergonomics (#3680)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] 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,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -22,6 +22,8 @@ import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -31,42 +33,6 @@ import {
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
||||
|
||||
function getStoredOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveOrder(ids: string[]) {
|
||||
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
/** Sort companies by stored order, appending any new ones at the end. */
|
||||
function sortByStoredOrder(companies: Company[]): Company[] {
|
||||
const order = getStoredOrder();
|
||||
if (order.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of order) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
sorted.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any companies not in stored order
|
||||
for (const c of byId.values()) {
|
||||
sorted.push(c);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
@@ -103,6 +69,10 @@ function SortableCompanyItem({
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
@@ -164,6 +134,11 @@ export function CompanyRail() {
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
@@ -195,52 +170,10 @@ export function CompanyRail() {
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
|
||||
);
|
||||
|
||||
// Re-sync orderedIds from localStorage whenever companies changes.
|
||||
// Handles initial data load (companies starts as [] before query resolves)
|
||||
// and subsequent refetches triggered by live updates.
|
||||
useEffect(() => {
|
||||
if (sidebarCompanies.length === 0) {
|
||||
setOrderedIds([]);
|
||||
return;
|
||||
}
|
||||
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
|
||||
}, [sidebarCompanies]);
|
||||
|
||||
// Sync order across tabs via the native storage event
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key !== ORDER_STORAGE_KEY) return;
|
||||
try {
|
||||
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
|
||||
setOrderedIds(ids);
|
||||
} catch { /* ignore malformed data */ }
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
// Re-derive when companies change (new company added/removed)
|
||||
const orderedCompanies = useMemo(() => {
|
||||
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
|
||||
const result: Company[] = [];
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
result.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any new companies not yet in our order
|
||||
for (const c of byId.values()) {
|
||||
result.push(c);
|
||||
}
|
||||
return result;
|
||||
}, [sidebarCompanies, orderedIds]);
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
@@ -260,11 +193,9 @@ export function CompanyRail() {
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newIds = arrayMove(ids, oldIndex, newIndex);
|
||||
setOrderedIds(newIds);
|
||||
saveOrder(newIds);
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies]
|
||||
[orderedCompanies, persistOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueFiltersPopover } from "./IssueFiltersPopover";
|
||||
import { defaultIssueFilterState } from "../lib/issue-filters";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="popover-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/checkbox", () => ({
|
||||
Checkbox: ({ checked }: { checked?: boolean }) => <input type="checkbox" checked={checked} readOnly />,
|
||||
}));
|
||||
|
||||
vi.mock("./StatusIcon", () => ({
|
||||
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./PriorityIcon", () => ({
|
||||
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
|
||||
}));
|
||||
|
||||
describe("IssueFiltersPopover", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("uses a scrollable popover and a three-column desktop grid", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueFiltersPopover
|
||||
state={defaultIssueFilterState}
|
||||
onChange={vi.fn()}
|
||||
activeFilterCount={0}
|
||||
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||
projects={[{ id: "project-1", name: "Project One" }]}
|
||||
labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]}
|
||||
workspaces={[{ id: "workspace-1", name: "Workspace One" }]}
|
||||
enableRoutineVisibilityFilter
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const popoverContent = container.querySelector("[data-testid='popover-content']");
|
||||
expect(popoverContent).not.toBeNull();
|
||||
expect(popoverContent?.className).toContain("overflow-y-auto");
|
||||
expect(popoverContent?.className).toContain("max-h-[min(80vh,42rem)]");
|
||||
|
||||
const layoutGrid = Array.from(popoverContent?.querySelectorAll("div") ?? []).find((element) =>
|
||||
element.className.includes("md:grid-cols-3"),
|
||||
);
|
||||
expect(layoutGrid?.className).toContain("grid-cols-1");
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,10 @@ export function IssueFiltersPopover({
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[min(780px,calc(100vw-2rem))] max-h-[min(80vh,42rem)] overflow-y-auto overscroll-contain p-0"
|
||||
>
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
@@ -120,24 +123,24 @@ export function IssueFiltersPopover({
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<div className="space-y-0.5">
|
||||
{issueStatusOrder.map((status) => (
|
||||
<label key={status} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.statuses.includes(status)}
|
||||
onCheckedChange={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })}
|
||||
/>
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-sm">{issueFilterLabel(status)}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<div className="space-y-0.5">
|
||||
{issueStatusOrder.map((status) => (
|
||||
<label key={status} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.statuses.includes(status)}
|
||||
onCheckedChange={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })}
|
||||
/>
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-sm">{issueFilterLabel(status)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Priority</span>
|
||||
<div className="space-y-0.5">
|
||||
@@ -153,7 +156,9 @@ export function IssueFiltersPopover({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
@@ -186,6 +191,25 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<label key={project.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.projects.includes(project.id)}
|
||||
onCheckedChange={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })}
|
||||
/>
|
||||
<span className="text-sm">{project.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
{labels && labels.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Labels</span>
|
||||
@@ -204,23 +228,6 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<label key={project.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.projects.includes(project.id)}
|
||||
onCheckedChange={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })}
|
||||
/>
|
||||
<span className="text-sm">{project.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Workspace</span>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type IssueGroupHeaderProps = {
|
||||
label: string;
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
trailing?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IssueGroupHeader({
|
||||
label,
|
||||
collapsible = false,
|
||||
collapsed = false,
|
||||
onToggle,
|
||||
trailing,
|
||||
className,
|
||||
}: IssueGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center py-1.5 pl-1 pr-3", className)}>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-1.5 text-left"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", !collapsed && "rotate-90")}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{trailing ? <div className="ml-auto">{trailing}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -351,8 +351,8 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the inbox issue column controls and persisted column visibility", async () => {
|
||||
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
it("uses context-scoped persisted column visibility", async () => {
|
||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
|
||||
const assignedIssue = createIssue({
|
||||
id: "issue-assigned",
|
||||
@@ -387,8 +387,41 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stored grouping across refresh when initial assignees are applied", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
JSON.stringify({ groupBy: "status", sortField: "updated", sortDir: "desc" }),
|
||||
);
|
||||
|
||||
const todoIssue = createIssue({ id: "issue-todo", title: "Alpha", status: "todo", assigneeAgentId: "agent-1" });
|
||||
const doneIssue = createIssue({ id: "issue-done", title: "Beta", status: "done", assigneeAgentId: "agent-1" });
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[todoIssue, doneIssue]}
|
||||
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialAssignees={["agent-1"]}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Todo");
|
||||
expect(container.textContent).toContain("Done");
|
||||
expect(container.textContent).toContain("Alpha");
|
||||
expect(container.textContent).toContain("Beta");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters the list to a single workspace when a workspace name is clicked", async () => {
|
||||
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"]));
|
||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"]));
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
getAvailableInboxIssueColumns,
|
||||
loadInboxIssueColumns,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveIssueWorkspaceName,
|
||||
saveInboxIssueColumns,
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -43,13 +41,14 @@ import {
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueGroupHeader } from "./IssueGroupHeader";
|
||||
import { IssueFiltersPopover } from "./IssueFiltersPopover";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
import { PageSkeleton } from "./PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
@@ -79,6 +78,7 @@ const defaultViewState: IssueViewState = {
|
||||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
};
|
||||
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
@@ -91,6 +91,43 @@ function saveViewState(key: string, state: IssueViewState) {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function getInitialViewState(key: string, initialAssignees?: string[]): IssueViewState {
|
||||
const stored = getViewState(key);
|
||||
if (!initialAssignees) return stored;
|
||||
return {
|
||||
...stored,
|
||||
assignees: initialAssignees,
|
||||
statuses: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getIssueColumnsStorageKey(key: string): string {
|
||||
return `${key}:issue-columns`;
|
||||
}
|
||||
|
||||
function loadIssueColumns(key: string): InboxIssueColumn[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getIssueColumnsStorageKey(key));
|
||||
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
return normalizeInboxIssueColumns(parsed);
|
||||
} catch {
|
||||
return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
getIssueColumnsStorageKey(key),
|
||||
JSON.stringify(normalizeInboxIssueColumns(columns)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
const sorted = [...issues];
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
@@ -240,17 +277,13 @@ export function IssuesList({
|
||||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
|
||||
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => {
|
||||
if (initialAssignees) {
|
||||
return { ...defaultViewState, assignees: initialAssignees, statuses: [] };
|
||||
}
|
||||
return getViewState(scopedKey);
|
||||
});
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => getInitialViewState(scopedKey, initialAssignees));
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
@@ -258,16 +291,23 @@ export function IssuesList({
|
||||
setIssueSearch(initialSearch ?? "");
|
||||
}, [initialSearch]);
|
||||
|
||||
// Reload view state from localStorage when company changes (scopedKey changes).
|
||||
const prevScopedKey = useRef(scopedKey);
|
||||
// Reload view state whenever the persisted context changes.
|
||||
const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}`);
|
||||
useEffect(() => {
|
||||
if (prevScopedKey.current !== scopedKey) {
|
||||
prevScopedKey.current = scopedKey;
|
||||
setViewState(initialAssignees
|
||||
? { ...defaultViewState, assignees: initialAssignees, statuses: [] }
|
||||
: getViewState(scopedKey));
|
||||
const nextContextKey = `${scopedKey}::${initialAssigneesKey}`;
|
||||
if (prevViewStateContextKey.current !== nextContextKey) {
|
||||
prevViewStateContextKey.current = nextContextKey;
|
||||
setViewState(getInitialViewState(scopedKey, initialAssignees));
|
||||
}
|
||||
}, [scopedKey, initialAssignees]);
|
||||
}, [scopedKey, initialAssignees, initialAssigneesKey]);
|
||||
|
||||
const prevColumnsScopedKey = useRef(scopedKey);
|
||||
useEffect(() => {
|
||||
if (prevColumnsScopedKey.current !== scopedKey) {
|
||||
prevColumnsScopedKey.current = scopedKey;
|
||||
setVisibleIssueColumns(loadIssueColumns(scopedKey));
|
||||
}
|
||||
}, [scopedKey]);
|
||||
|
||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||
setViewState((prev) => {
|
||||
@@ -521,8 +561,8 @@ export function IssuesList({
|
||||
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||
const normalized = normalizeInboxIssueColumns(next);
|
||||
setVisibleIssueColumns(normalized);
|
||||
saveInboxIssueColumns(normalized);
|
||||
}, []);
|
||||
saveIssueColumns(scopedKey, normalized);
|
||||
}, [scopedKey]);
|
||||
|
||||
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
@@ -723,22 +763,28 @@ export function IssuesList({
|
||||
}}
|
||||
>
|
||||
{group.label && (
|
||||
<div className="flex items-center py-1.5 pl-1 pr-3">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={viewState.collapsedGroups.includes(group.key)}
|
||||
onToggle={() => {
|
||||
updateView({
|
||||
collapsedGroups: viewState.collapsedGroups.includes(group.key)
|
||||
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
||||
: [...viewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
trailing={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{(() => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import { ProjectWorkspaceSummaryCard } from "./ProjectWorkspaceSummaryCard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: overrides.id ?? "issue-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||
goalId: overrides.goalId ?? null,
|
||||
parentId: overrides.parentId ?? null,
|
||||
title: overrides.title ?? "Issue",
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? "todo",
|
||||
priority: overrides.priority ?? "medium",
|
||||
assigneeAgentId: overrides.assigneeAgentId ?? null,
|
||||
assigneeUserId: overrides.assigneeUserId ?? null,
|
||||
checkoutRunId: overrides.checkoutRunId ?? null,
|
||||
executionRunId: overrides.executionRunId ?? null,
|
||||
executionAgentNameKey: overrides.executionAgentNameKey ?? null,
|
||||
executionLockedAt: overrides.executionLockedAt ?? null,
|
||||
createdByAgentId: overrides.createdByAgentId ?? null,
|
||||
createdByUserId: overrides.createdByUserId ?? null,
|
||||
issueNumber: overrides.issueNumber ?? 1,
|
||||
identifier: overrides.identifier ?? "PAP-1",
|
||||
requestDepth: overrides.requestDepth ?? 0,
|
||||
billingCode: overrides.billingCode ?? null,
|
||||
assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null,
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: overrides.executionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null,
|
||||
startedAt: overrides.startedAt ?? null,
|
||||
completedAt: overrides.completedAt ?? null,
|
||||
cancelledAt: overrides.cancelledAt ?? null,
|
||||
hiddenAt: overrides.hiddenAt ?? null,
|
||||
createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00Z"),
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): ProjectWorkspaceSummary {
|
||||
return {
|
||||
key: overrides.key ?? "execution:workspace-1",
|
||||
kind: overrides.kind ?? "execution_workspace",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-1",
|
||||
workspaceName: overrides.workspaceName ?? "PAP-989-multi-user-implementation",
|
||||
cwd: overrides.cwd ?? "/worktrees/PAP-989-multi-user-implementation",
|
||||
branchName: overrides.branchName ?? "PAP-989-multi-user-implementation",
|
||||
lastUpdatedAt: overrides.lastUpdatedAt ?? new Date("2026-04-12T00:00:00Z"),
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? "project-workspace-1",
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? "workspace-1",
|
||||
executionWorkspaceStatus: overrides.executionWorkspaceStatus ?? "active",
|
||||
serviceCount: overrides.serviceCount ?? 2,
|
||||
runningServiceCount: overrides.runningServiceCount ?? 0,
|
||||
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474",
|
||||
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
||||
issues: overrides.issues ?? [
|
||||
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
||||
createIssue({ id: "issue-2", identifier: "PAP-1367" }),
|
||||
createIssue({ id: "issue-3", identifier: "PAP-1362" }),
|
||||
createIssue({ id: "issue-4", identifier: "PAP-1363" }),
|
||||
createIssue({ id: "issue-5", identifier: "PAP-1340" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProjectWorkspaceSummaryCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders a stacked mobile-friendly summary with metadata labels and compact issue pills", () => {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={createSummary()}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={() => {}}
|
||||
onCloseWorkspace={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Execution workspace");
|
||||
expect(container.textContent).toContain("Branch");
|
||||
expect(container.textContent).toContain("Path");
|
||||
expect(container.textContent).toContain("Service");
|
||||
expect(container.textContent).toContain("Linked issues");
|
||||
expect(container.textContent).toContain("Start services");
|
||||
expect(container.textContent).toContain("Close workspace");
|
||||
expect(container.textContent).toContain("+1 more");
|
||||
|
||||
const actions = container.querySelector('[data-testid="workspace-summary-actions"]');
|
||||
expect(actions?.className).toContain("flex-col");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses project workspace routes and omits close controls for project workspaces", () => {
|
||||
const runtimeSpy = vi.fn();
|
||||
const closeSpy = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={createSummary({
|
||||
key: "project:workspace-2",
|
||||
kind: "project_workspace",
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
hasRuntimeConfig: false,
|
||||
issues: [createIssue({ id: "issue-6", identifier: "PAP-1400" })],
|
||||
})}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={runtimeSpy}
|
||||
onCloseWorkspace={closeSpy}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const titleLink = container.querySelector("a[href='/projects/paperclip-app/workspaces/workspace-1']");
|
||||
expect(titleLink).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Close workspace");
|
||||
expect(container.textContent).not.toContain("Start services");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry close for cleanup failures", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={createSummary({
|
||||
executionWorkspaceStatus: "cleanup_failed" as ExecutionWorkspace["status"],
|
||||
})}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={() => {}}
|
||||
onCloseWorkspace={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Retry close");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Link } from "@/lib/router";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyText } from "./CopyText";
|
||||
import { IssuesQuicklook } from "./IssuesQuicklook";
|
||||
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Copy, ExternalLink, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
|
||||
|
||||
function workspaceKindLabel(kind: ProjectWorkspaceSummary["kind"]) {
|
||||
return kind === "execution_workspace" ? "Execution workspace" : "Project workspace";
|
||||
}
|
||||
|
||||
function truncatePath(path: string) {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length <= 3) return path;
|
||||
return `…/${parts.slice(-3).join("/")}`;
|
||||
}
|
||||
|
||||
interface ProjectWorkspaceSummaryCardProps {
|
||||
projectRef: string;
|
||||
summary: ProjectWorkspaceSummary;
|
||||
runtimeActionKey: string | null;
|
||||
runtimeActionPending: boolean;
|
||||
onRuntimeAction: (input: {
|
||||
key: string;
|
||||
kind: "project_workspace" | "execution_workspace";
|
||||
workspaceId: string;
|
||||
action: "start" | "stop" | "restart";
|
||||
}) => void;
|
||||
onCloseWorkspace: (input: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ExecutionWorkspace["status"];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function ProjectWorkspaceSummaryCard({
|
||||
projectRef,
|
||||
summary,
|
||||
runtimeActionKey,
|
||||
runtimeActionPending,
|
||||
onRuntimeAction,
|
||||
onCloseWorkspace,
|
||||
}: ProjectWorkspaceSummaryCardProps) {
|
||||
const visibleIssues = summary.issues.slice(0, 4);
|
||||
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||
const workspaceHref =
|
||||
summary.kind === "project_workspace"
|
||||
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
||||
: `/execution-workspaces/${summary.workspaceId}`;
|
||||
const hasRunningServices = summary.runningServiceCount > 0;
|
||||
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="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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/25 px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{workspaceKindLabel(summary.kind)}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
Updated {timeAgo(summary.lastUpdatedAt)}
|
||||
</span>
|
||||
{summary.serviceCount > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
hasRunningServices
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-border/70 bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
hasRunningServices ? "bg-emerald-500" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
{summary.runningServiceCount}/{summary.serviceCount} services
|
||||
</span>
|
||||
) : null}
|
||||
{summary.executionWorkspaceStatus ? (
|
||||
<span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{summary.executionWorkspaceStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="block break-words text-base font-semibold leading-6 text-foreground hover:underline"
|
||||
>
|
||||
{summary.workspaceName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-2 min-[420px]:flex-row lg:w-auto lg:justify-end"
|
||||
data-testid="workspace-summary-actions"
|
||||
>
|
||||
{summary.hasRuntimeConfig ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 justify-center px-3 text-xs"
|
||||
disabled={runtimeActionPending}
|
||||
onClick={() =>
|
||||
onRuntimeAction({
|
||||
key: summary.key,
|
||||
kind: summary.kind,
|
||||
workspaceId: summary.workspaceId,
|
||||
action: hasRunningServices ? "stop" : "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
{runtimeActionKey === actionKey ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : hasRunningServices ? (
|
||||
<Square className="mr-2 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{hasRunningServices ? "Stop services" : "Start services"}
|
||||
</Button>
|
||||
) : null}
|
||||
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs text-muted-foreground"
|
||||
onClick={() => onCloseWorkspace({
|
||||
id: summary.executionWorkspaceId!,
|
||||
name: summary.workspaceName,
|
||||
status: summary.executionWorkspaceStatus!,
|
||||
})}
|
||||
>
|
||||
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-muted/15 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="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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary.cwd ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<FolderOpen className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<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}>
|
||||
{truncatePath(summary.cwd)}
|
||||
</span>
|
||||
<CopyText text={summary.cwd} 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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary.primaryServiceUrl ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Service</div>
|
||||
<a
|
||||
href={summary.primaryServiceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all font-mono text-xs text-foreground hover:underline"
|
||||
>
|
||||
{summary.primaryServiceUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.issues.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Linked issues
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleIssues.map((issue) => (
|
||||
<IssuePill key={issue.id} issue={issue} />
|
||||
))}
|
||||
{hiddenIssueCount > 0 ? (
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
+{hiddenIssueCount} more
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssuePill({ issue }: { issue: Issue }) {
|
||||
return (
|
||||
<IssuesQuicklook issue={issue}>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 font-mono text-xs text-foreground transition-colors hover:border-foreground/30 hover:text-foreground hover:underline"
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</Link>
|
||||
</IssuesQuicklook>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,11 @@ function SortableProjectItem({
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlItems,
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeControls,
|
||||
} from "./WorkspaceRuntimeControls";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
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 ?? "workspace-1",
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||
issueId: overrides.issueId ?? null,
|
||||
scopeType: overrides.scopeType ?? "project_workspace",
|
||||
scopeId: overrides.scopeId ?? "workspace-1",
|
||||
serviceName: overrides.serviceName ?? "web",
|
||||
status: overrides.status ?? "stopped",
|
||||
lifecycle: overrides.lifecycle ?? "shared",
|
||||
reuseKey: overrides.reuseKey ?? null,
|
||||
command: overrides.command ?? "pnpm dev",
|
||||
cwd: overrides.cwd ?? "/repo",
|
||||
port: overrides.port ?? null,
|
||||
url: overrides.url ?? null,
|
||||
provider: overrides.provider ?? "local_process",
|
||||
providerRef: overrides.providerRef ?? null,
|
||||
ownerAgentId: overrides.ownerAgentId ?? null,
|
||||
startedByRunId: overrides.startedByRunId ?? null,
|
||||
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
||||
startedAt: overrides.startedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
||||
stoppedAt: overrides.stoppedAt ?? null,
|
||||
stopPolicy: overrides.stopPolicy ?? null,
|
||||
healthStatus: overrides.healthStatus ?? "unknown",
|
||||
configIndex: overrides.configIndex ?? null,
|
||||
createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceRuntimeControlSections", () => {
|
||||
it("separates service and job commands while matching running services", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
canRunJobs: true,
|
||||
});
|
||||
|
||||
expect(sections.services).toHaveLength(1);
|
||||
expect(sections.jobs).toHaveLength(1);
|
||||
expect(sections.services[0]).toMatchObject({
|
||||
title: "web",
|
||||
statusLabel: "running",
|
||||
workspaceCommandId: "web",
|
||||
runtimeServiceId: "service-web",
|
||||
});
|
||||
expect(sections.jobs[0]).toMatchObject({
|
||||
title: "db:migrate",
|
||||
statusLabel: "run once",
|
||||
workspaceCommandId: "db-migrate",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceRuntimeControlItems", () => {
|
||||
it("keeps the legacy flat export shape for stale importers", () => {
|
||||
const items = buildWorkspaceRuntimeControlItems({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
canRunJobs: true,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
title: "web",
|
||||
status: "running",
|
||||
statusLabel: "running",
|
||||
runtimeServiceId: "service-web",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceRuntimeControls", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders service and job actions distinctly", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
canRunJobs: true,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeControls
|
||||
sections={sections}
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
||||
expect(buttons).toEqual(["Stop", "Restart", "Run"]);
|
||||
expect(container.textContent).toContain("Services");
|
||||
expect(container.textContent).toContain("Jobs");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows disabled actions when local command prerequisites are missing", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [],
|
||||
canStartServices: false,
|
||||
canRunJobs: false,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeControls
|
||||
sections={sections}
|
||||
disabledHint="Add a workspace path first."
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
expect(buttons.every((button) => button.hasAttribute("disabled"))).toBe(true);
|
||||
expect(container.textContent).toContain("Add a workspace path first.");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the disabled hint once services can already run", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeControls
|
||||
sections={sections}
|
||||
disabledHint="Add runtime settings first."
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Add runtime settings first.");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the health badge for stopped services", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "stopped", healthStatus: "unknown" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeControls
|
||||
sections={sections}
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("unknown");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("accepts the legacy items prop without crashing", () => {
|
||||
const items = buildWorkspaceRuntimeControlItems({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [],
|
||||
canStartServices: false,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeControls
|
||||
items={items}
|
||||
emptyMessage="No runtime services have been started yet."
|
||||
disabledHint="Add runtime settings first."
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Services");
|
||||
expect(container.textContent).toContain("Add runtime settings first.");
|
||||
expect(Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual(["Start"]);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import type {
|
||||
WorkspaceCommandDefinition,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
listWorkspaceCommandDefinitions,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
} from "@paperclipai/shared";
|
||||
import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type WorkspaceRuntimeAction = "start" | "stop" | "restart" | "run";
|
||||
|
||||
export type WorkspaceRuntimeControlRequest = WorkspaceRuntimeControlTarget & {
|
||||
action: WorkspaceRuntimeAction;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeControlItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
kind: "service" | "job";
|
||||
statusLabel: string;
|
||||
lifecycle: "shared" | "ephemeral" | null;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy" | null;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
port: number | null;
|
||||
url: string | null;
|
||||
canStart: boolean;
|
||||
canRun: boolean;
|
||||
workspaceCommandId?: string | null;
|
||||
runtimeServiceId?: string | null;
|
||||
serviceIndex?: number | null;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeControlSections = {
|
||||
services: WorkspaceRuntimeControlItem[];
|
||||
jobs: WorkspaceRuntimeControlItem[];
|
||||
otherServices: WorkspaceRuntimeControlItem[];
|
||||
};
|
||||
|
||||
type LegacyWorkspaceRuntimeControlItem = WorkspaceRuntimeControlItem & {
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
type WorkspaceRuntimeControlsProps = {
|
||||
sections: WorkspaceRuntimeControlSections;
|
||||
items?: never;
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
serviceEmptyMessage?: string;
|
||||
jobEmptyMessage?: string;
|
||||
emptyMessage?: never;
|
||||
disabledHint?: string | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
className?: string;
|
||||
} | {
|
||||
sections?: never;
|
||||
items: LegacyWorkspaceRuntimeControlItem[];
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
serviceEmptyMessage?: never;
|
||||
jobEmptyMessage?: never;
|
||||
emptyMessage?: string;
|
||||
disabledHint?: string | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function hasRunningRuntimeServices(
|
||||
runtimeServices: Array<{ status: string }> | null | undefined,
|
||||
) {
|
||||
return (runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
|
||||
}
|
||||
|
||||
function buildServiceItem(
|
||||
command: WorkspaceCommandDefinition,
|
||||
runtimeService: WorkspaceRuntimeService | null,
|
||||
canStartServices: boolean,
|
||||
): WorkspaceRuntimeControlItem {
|
||||
return {
|
||||
key: `command:${command.id}:${runtimeService?.id ?? "idle"}`,
|
||||
title: command.name,
|
||||
kind: "service",
|
||||
statusLabel: runtimeService?.status ?? "stopped",
|
||||
lifecycle: runtimeService?.lifecycle ?? command.lifecycle,
|
||||
healthStatus: runtimeService?.healthStatus ?? "unknown",
|
||||
command: runtimeService?.command ?? command.command,
|
||||
cwd: runtimeService?.cwd ?? command.cwd,
|
||||
port: runtimeService?.port ?? null,
|
||||
url: runtimeService?.url ?? null,
|
||||
canStart: canStartServices && !command.disabledReason,
|
||||
canRun: false,
|
||||
workspaceCommandId: command.id,
|
||||
runtimeServiceId: runtimeService?.id ?? null,
|
||||
serviceIndex: command.serviceIndex,
|
||||
disabledReason: command.disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJobItem(
|
||||
command: WorkspaceCommandDefinition,
|
||||
canRunJobs: boolean,
|
||||
): WorkspaceRuntimeControlItem {
|
||||
return {
|
||||
key: `command:${command.id}`,
|
||||
title: command.name,
|
||||
kind: "job",
|
||||
statusLabel: "run once",
|
||||
lifecycle: null,
|
||||
healthStatus: null,
|
||||
command: command.command,
|
||||
cwd: command.cwd,
|
||||
port: null,
|
||||
url: null,
|
||||
canStart: false,
|
||||
canRun: canRunJobs && !command.disabledReason && Boolean(command.command),
|
||||
workspaceCommandId: command.id,
|
||||
runtimeServiceId: null,
|
||||
serviceIndex: null,
|
||||
disabledReason: command.disabledReason ?? (!command.command ? "This job is missing a command." : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceRuntimeControlSections(input: {
|
||||
runtimeConfig: Record<string, unknown> | null | undefined;
|
||||
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
|
||||
canStartServices: boolean;
|
||||
canRunJobs?: boolean;
|
||||
}): WorkspaceRuntimeControlSections {
|
||||
const commands = listWorkspaceCommandDefinitions(input.runtimeConfig);
|
||||
const runtimeServices = [...(input.runtimeServices ?? [])];
|
||||
const matchedRuntimeServiceIds = new Set<string>();
|
||||
const services: WorkspaceRuntimeControlItem[] = [];
|
||||
const jobs: WorkspaceRuntimeControlItem[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
if (command.kind === "job") {
|
||||
jobs.push(buildJobItem(command, input.canRunJobs ?? input.canStartServices));
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtimeService = matchWorkspaceRuntimeServiceToCommand(command, runtimeServices);
|
||||
if (runtimeService) matchedRuntimeServiceIds.add(runtimeService.id);
|
||||
services.push(buildServiceItem(command, runtimeService, input.canStartServices));
|
||||
}
|
||||
|
||||
const otherServices = runtimeServices
|
||||
.filter((runtimeService) => !matchedRuntimeServiceIds.has(runtimeService.id))
|
||||
.map((runtimeService) => ({
|
||||
key: `runtime:${runtimeService.id}`,
|
||||
title: runtimeService.serviceName,
|
||||
kind: "service" as const,
|
||||
statusLabel: runtimeService.status,
|
||||
lifecycle: runtimeService.lifecycle,
|
||||
healthStatus: runtimeService.healthStatus,
|
||||
command: runtimeService.command ?? null,
|
||||
cwd: runtimeService.cwd ?? null,
|
||||
port: runtimeService.port ?? null,
|
||||
url: runtimeService.url ?? null,
|
||||
canStart: false,
|
||||
canRun: false,
|
||||
workspaceCommandId: null,
|
||||
runtimeServiceId: runtimeService.id,
|
||||
serviceIndex: runtimeService.configIndex ?? null,
|
||||
disabledReason: "This runtime service no longer matches a configured workspace command.",
|
||||
}));
|
||||
|
||||
return {
|
||||
services,
|
||||
jobs,
|
||||
otherServices,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceRuntimeControlItems(input: {
|
||||
runtimeConfig: Record<string, unknown> | null | undefined;
|
||||
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
|
||||
canStartServices: boolean;
|
||||
canRunJobs?: boolean;
|
||||
}): LegacyWorkspaceRuntimeControlItem[] {
|
||||
return buildWorkspaceRuntimeControlSections(input).services.map((item) => ({
|
||||
...item,
|
||||
status: item.statusLabel,
|
||||
}));
|
||||
}
|
||||
|
||||
function requestMatchesPending(
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
||||
nextRequest: WorkspaceRuntimeControlRequest,
|
||||
) {
|
||||
return pendingRequest?.action === nextRequest.action
|
||||
&& (pendingRequest?.workspaceCommandId ?? null) === (nextRequest.workspaceCommandId ?? null)
|
||||
&& (pendingRequest?.runtimeServiceId ?? null) === (nextRequest.runtimeServiceId ?? null)
|
||||
&& (pendingRequest?.serviceIndex ?? null) === (nextRequest.serviceIndex ?? null);
|
||||
}
|
||||
|
||||
function buildRequest(item: WorkspaceRuntimeControlItem, action: WorkspaceRuntimeAction): WorkspaceRuntimeControlRequest {
|
||||
return {
|
||||
action,
|
||||
workspaceCommandId: item.workspaceCommandId ?? null,
|
||||
runtimeServiceId: item.runtimeServiceId ?? null,
|
||||
serviceIndex: item.serviceIndex ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function CommandActionButtons({
|
||||
item,
|
||||
isPending,
|
||||
pendingRequest,
|
||||
onAction,
|
||||
}: {
|
||||
item: WorkspaceRuntimeControlItem;
|
||||
isPending: boolean;
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
}) {
|
||||
const actions: WorkspaceRuntimeAction[] =
|
||||
item.kind === "job"
|
||||
? ["run"]
|
||||
: item.statusLabel === "running" || item.statusLabel === "starting"
|
||||
? ["stop", ...(item.canStart ? ["restart" as const] : [])]
|
||||
: ["start"];
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
{actions.map((action) => {
|
||||
const request = buildRequest(item, action);
|
||||
const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play;
|
||||
const label = action === "run"
|
||||
? "Run"
|
||||
: action === "start"
|
||||
? "Start"
|
||||
: action === "stop"
|
||||
? "Stop"
|
||||
: "Restart";
|
||||
const showSpinner = isPending && requestMatchesPending(pendingRequest, request);
|
||||
const disabled =
|
||||
isPending
|
||||
|| (action === "run" && !item.canRun)
|
||||
|| ((action === "start" || action === "restart") && !item.canStart);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`${item.key}:${action}`}
|
||||
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",
|
||||
action === "restart" ? "bg-background" : null,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(request)}
|
||||
>
|
||||
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
emptyMessage,
|
||||
disabledHint,
|
||||
isPending,
|
||||
pendingRequest,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: WorkspaceRuntimeControlItem[];
|
||||
emptyMessage: string;
|
||||
disabledHint?: string | null;
|
||||
isPending: boolean;
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<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">
|
||||
{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 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">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.kind} · {item.statusLabel}
|
||||
{item.lifecycle ? ` · ${item.lifecycle}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<CommandActionButtons
|
||||
item={item}
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{item.url ? (
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{item.url}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{item.port ? <div>Port {item.port}</div> : null}
|
||||
{item.command ? <div className="break-all font-mono">{item.command}</div> : null}
|
||||
{item.cwd ? <div className="break-all font-mono">{item.cwd}</div> : null}
|
||||
{item.disabledReason ? <div>{item.disabledReason}</div> : null}
|
||||
</div>
|
||||
{item.healthStatus && item.statusLabel !== "stopped" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]",
|
||||
item.healthStatus === "healthy"
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: item.healthStatus === "unhealthy"
|
||||
? "border-destructive/30 bg-destructive/10 text-destructive"
|
||||
: "border-border text-muted-foreground",
|
||||
)}>
|
||||
{item.healthStatus}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRuntimeControls({
|
||||
sections,
|
||||
items,
|
||||
isPending = false,
|
||||
pendingRequest = null,
|
||||
serviceEmptyMessage = "No services are configured for this workspace.",
|
||||
jobEmptyMessage = "No one-shot jobs are configured for this workspace.",
|
||||
emptyMessage,
|
||||
disabledHint = null,
|
||||
onAction,
|
||||
className,
|
||||
}: WorkspaceRuntimeControlsProps) {
|
||||
const resolvedSections = sections ?? {
|
||||
services: (items ?? []).map((item) => ({
|
||||
...item,
|
||||
statusLabel: item.statusLabel ?? item.status ?? "stopped",
|
||||
})),
|
||||
jobs: [],
|
||||
otherServices: [],
|
||||
};
|
||||
const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage;
|
||||
const runningCount = resolvedSections.services.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="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">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
runningCount > 0
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-border bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{runningCount > 0 ? `${runningCount} services running` : "No services running"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{resolvedSections.jobs.length > 0
|
||||
? `${resolvedSections.jobs.length} job${resolvedSections.jobs.length === 1 ? "" : "s"} available to run on demand.`
|
||||
: "Each command can be controlled independently."}
|
||||
</span>
|
||||
</div>
|
||||
{visibleDisabledHint ? <p className="text-xs text-muted-foreground">{visibleDisabledHint}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandSection
|
||||
title="Services"
|
||||
description="Long-running commands that Paperclip can supervise for this workspace."
|
||||
items={resolvedSections.services}
|
||||
emptyMessage={resolvedServiceEmptyMessage}
|
||||
disabledHint={visibleDisabledHint}
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
/>
|
||||
|
||||
<CommandSection
|
||||
title="Jobs"
|
||||
description="One-shot commands that run now and exit when they finish."
|
||||
items={resolvedSections.jobs}
|
||||
emptyMessage={jobEmptyMessage}
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
/>
|
||||
|
||||
{resolvedSections.otherServices.length > 0 ? (
|
||||
<CommandSection
|
||||
title="Untracked services"
|
||||
description="Running services that no longer match the current workspace command config."
|
||||
items={resolvedSections.otherServices}
|
||||
emptyMessage=""
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user