Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s

Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:01:31 -04:00
536 changed files with 60296 additions and 2542 deletions
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,6 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({
@@ -61,14 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function flushReact() {
await act(async () => {
for (let i = 0; i < 3; i += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
describe("CompanySettingsSidebar", () => {
@@ -83,6 +100,17 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0,
joinRequests: 2,
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: false,
});
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: false,
});
});
afterEach(() => {
@@ -110,7 +138,9 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Access");
expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Members");
expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
@@ -129,8 +159,8 @@ describe("CompanySettingsSidebar", () => {
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/access",
label: "Access",
to: "/company/settings/members",
label: "Members",
badge: 2,
end: true,
}),
@@ -154,4 +184,114 @@ describe("CompanySettingsSidebar", () => {
root.unmount();
});
});
it("shows cloud upstream only when cloud sync is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: true,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Cloud upstream");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/cloud-upstream",
label: "Cloud upstream",
end: true,
}),
);
await act(async () => {
root.unmount();
});
});
it("renders company settings pages contributed by ready plugins", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],
isLoading: false,
errorMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Permissions");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/permissions",
label: "Permissions",
end: true,
}),
);
await act(async () => {
root.unmount();
});
});
it("shows cloud upstream only when cloud sync is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: true,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Cloud upstream");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/cloud-upstream",
label: "Cloud upstream",
end: true,
}),
);
await act(async () => {
root.unmount();
});
});
});
+35 -4
View File
@@ -1,16 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { slots: companySettingsPluginSlots } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const { data: badges } = useQuery({
queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId)
@@ -29,6 +36,11 @@ export function CompanySettingsSidebar() {
retry: false,
refetchInterval: 15_000,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const showCloudUpstream = experimentalSettings?.enableCloudSync === true;
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
@@ -61,13 +73,32 @@ export function CompanySettingsSidebar() {
end
/>
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
{showCloudUpstream ? (
<SidebarNavItem
to="/company/settings/cloud-upstream"
label="Cloud upstream"
icon={CloudUpload}
end
/>
) : null}
<SidebarNavItem
to="/company/settings/access"
label="Access"
icon={Shield}
to="/company/settings/members"
label="Members"
icon={Users}
badge={badges?.joinRequests ?? 0}
end
/>
{companySettingsPluginSlots
.filter((slot) => slot.routePath)
.map((slot) => (
<SidebarNavItem
key={`${slot.pluginKey}:${slot.id}`}
to={`/company/settings/${slot.routePath}`}
label={slot.displayName}
icon={Puzzle}
end
/>
))}
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
</div>
</nav>
+113
View File
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevRestartBanner } from "./DevRestartBanner";
const mockHealthApi = vi.hoisted(() => ({
requestDevServerRestart: vi.fn(),
}));
vi.mock("../api/health", () => ({
healthApi: mockHealthApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
const devServer = {
enabled: true as const,
restartRequired: true,
reason: "backend_changes" as const,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
autoRestartEnabled: true,
activeRunCount: 1,
waitingForIdle: true,
lastRestartAt: "2026-03-20T11:30:00.000Z",
};
beforeEach(() => {
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "alert").mockImplementation(() => undefined);
mockHealthApi.requestDevServerRestart.mockResolvedValue(undefined);
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
vi.restoreAllMocks();
vi.useRealTimers();
mockHealthApi.requestDevServerRestart.mockReset();
});
function render() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(<DevRestartBanner devServer={devServer} />));
return container;
}
describe("DevRestartBanner", () => {
it("confirms and requests an immediate restart while waiting for live runs", async () => {
const node = render();
const button = [...node.querySelectorAll("button")]
.find((entry) => entry.textContent?.includes("Restart now"));
expect(node.textContent).toContain("Waiting for 1 live run to finish");
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(window.confirm).toHaveBeenCalledWith("Restart Paperclip now? This may interrupt 1 live run.");
expect(mockHealthApi.requestDevServerRestart).toHaveBeenCalledTimes(1);
expect(node.textContent).toContain("Restart requested");
});
it("does not request restart when confirmation is declined", async () => {
vi.mocked(window.confirm).mockReturnValue(false);
const node = render();
const button = [...node.querySelectorAll("button")]
.find((entry) => entry.textContent?.includes("Restart now"));
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockHealthApi.requestDevServerRestart).not.toHaveBeenCalled();
});
it("re-enables the manual restart action when a request does not refresh the page", async () => {
vi.useFakeTimers();
const node = render();
const button = [...node.querySelectorAll("button")]
.find((entry) => entry.textContent?.includes("Restart now")) as HTMLButtonElement | undefined;
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(button?.disabled).toBe(true);
expect(node.textContent).toContain("Restart requested");
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(button?.disabled).toBe(false);
expect(node.textContent).toContain("Restart now");
});
});
+46 -3
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import type { DevServerHealthStatus } from "../api/health";
import { healthApi, type DevServerHealthStatus } from "../api/health";
const RESTART_PENDING_RESET_MS = 30_000;
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
@@ -27,10 +30,39 @@ function describeReason(devServer: DevServerHealthStatus): string {
}
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
const [restartPending, setRestartPending] = useState(false);
useEffect(() => {
if (!restartPending) return;
const timeout = window.setTimeout(() => {
setRestartPending(false);
}, RESTART_PENDING_RESET_MS);
return () => window.clearTimeout(timeout);
}, [restartPending]);
if (!devServer?.enabled || !devServer.restartRequired) return null;
const currentDevServer = devServer;
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
const sample = devServer.changedPathsSample.slice(0, 3);
const activeRunLabel = `${devServer.activeRunCount} live run${
devServer.activeRunCount === 1 ? "" : "s"
}`;
async function requestRestartNow() {
const warning =
currentDevServer.activeRunCount > 0
? `Restart Paperclip now? This may interrupt ${activeRunLabel}.`
: "Restart Paperclip now?";
if (!window.confirm(warning)) return;
setRestartPending(true);
try {
await healthApi.requestDevServerRestart();
} catch (error) {
setRestartPending(false);
window.alert(error instanceof Error ? error.message : "Failed to request restart");
}
}
return (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
@@ -65,11 +97,11 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium md:justify-end">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
<span>Waiting for {activeRunLabel} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
@@ -82,6 +114,17 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
</div>
)}
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-amber-950 px-3 py-1.5 text-xs font-semibold text-amber-50 transition-colors hover:bg-amber-900 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
onClick={() => {
void requestRestartNow();
}}
disabled={restartPending}
>
<RotateCcw className="h-3.5 w-3.5" />
<span>{restartPending ? "Restart requested" : "Restart now"}</span>
</button>
</div>
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { EntityRow } from "./EntityRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: React.ComponentProps<"a"> & { to: string }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
describe("EntityRow", () => {
it("keeps caller text color classes on linked rows", () => {
const markup = renderToStaticMarkup(
<EntityRow
title="Left project"
to="/projects/left-project"
className="group text-foreground/55"
/>,
);
expect(markup).toContain("text-foreground/55");
expect(markup).not.toContain("text-inherit");
});
});
+10 -3
View File
@@ -12,6 +12,7 @@ interface EntityRowProps {
to?: string;
onClick?: () => void;
className?: string;
reserveSubtitleSpace?: boolean;
}
export function EntityRow({
@@ -24,6 +25,7 @@ export function EntityRow({
to,
onClick,
className,
reserveSubtitleSpace,
}: EntityRowProps) {
const isClickable = !!(to || onClick);
const classes = cn(
@@ -45,8 +47,13 @@ export function EntityRow({
)}
<span className="truncate">{title}</span>
</div>
{subtitle && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>
{(subtitle || reserveSubtitleSpace) && (
<p
className={cn("text-xs text-muted-foreground truncate mt-0.5 min-h-4", !subtitle && "invisible")}
aria-hidden={!subtitle}
>
{subtitle}
</p>
)}
</div>
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
@@ -55,7 +62,7 @@ export function EntityRow({
if (to) {
return (
<Link to={to} className={cn(classes, "no-underline text-inherit")} onClick={onClick}>
<Link to={to} className={cn("no-underline text-inherit", classes)} onClick={onClick}>
{content}
</Link>
);
+280
View File
@@ -0,0 +1,280 @@
// @vitest-environment jsdom
import { act, 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 type { PluginRecord } from "@paperclipai/shared";
const mockPluginsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/lib/router", () => ({
NavLink: ({
children,
to,
className,
}: {
children: ReactNode | ((arg: { isActive: boolean }) => ReactNode);
to: string;
state?: unknown;
end?: boolean;
onClick?: () => void;
className?: string | ((arg: { isActive: boolean }) => string);
}) => {
const resolvedClass =
typeof className === "function" ? className({ isActive: false }) : className;
const content = typeof children === "function" ? children({ isActive: false }) : children;
return (
<a href={to} className={resolvedClass}>
{content}
</a>
);
},
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
import { InstanceSidebar } from "./InstanceSidebar";
function makePlugin(overrides: Partial<PluginRecord> & { manifestJson: PluginRecord["manifestJson"] }): PluginRecord {
return {
id: overrides.id ?? "plugin-id",
pluginKey: overrides.pluginKey ?? "plugin-key",
packageName: overrides.packageName ?? "@scope/pkg",
version: overrides.version ?? "1.0.0",
apiVersion: overrides.apiVersion ?? 1,
categories: overrides.categories ?? [],
manifestJson: overrides.manifestJson,
status: overrides.status ?? "ready",
installOrder: overrides.installOrder ?? 0,
packagePath: overrides.packagePath ?? null,
lastError: overrides.lastError ?? null,
installedAt: overrides.installedAt ?? new Date(0),
updatedAt: overrides.updatedAt ?? new Date(0),
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function findPluginLinks(container: HTMLElement, expectedCount: number) {
await act(async () => {
await vi.waitFor(() => {
expect(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')).toHaveLength(expectedCount);
});
});
return Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/plugins/"]'));
}
function renderSidebar(container: HTMLElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<InstanceSidebar />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("InstanceSidebar", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = null;
mockPluginsApi.list.mockReset();
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => {
currentRoot.unmount();
});
}
queryClient?.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("filters out sandbox-provider-only plugins from the sidebar", async () => {
const sandboxPlugin = makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B Sandbox Provider",
displayName: "E2B Sandbox Provider",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
const regularPlugin = makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([sandboxPlugin, regularPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/linear");
expect(pluginLinks[0]?.textContent).toBe("Linear");
});
it("keeps plugins that mix sandbox-provider with other contributions", async () => {
const hybridPlugin = makePlugin({
id: "hybrid",
packageName: "@example/plugin-hybrid",
manifestJson: {
id: "hybrid",
name: "Hybrid",
displayName: "Hybrid",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "sb",
kind: "sandbox_provider",
displayName: "SB",
configSchema: { type: "object" },
},
{
driverKey: "env",
kind: "environment_driver",
displayName: "Env",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([hybridPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/hybrid");
});
it("renders the indented plugin list between the Plugins and Adapters rows", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await findPluginLinks(container, 1);
await vi.waitFor(() => {
const links = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'),
);
expect(links.some((a) => a.getAttribute("href") === "/instance/settings/plugins/linear")).toBe(true);
});
const topLevelLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'));
const hrefs = topLevelLinks.map((a) => a.getAttribute("href"));
const pluginsIndex = hrefs.indexOf("/instance/settings/plugins");
const adaptersIndex = hrefs.indexOf("/instance/settings/adapters");
const linearIndex = hrefs.indexOf("/instance/settings/plugins/linear");
expect(pluginsIndex).toBeGreaterThanOrEqual(0);
expect(adaptersIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeLessThan(adaptersIndex);
});
it("does not render the indented group when every plugin is filtered out", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B",
displayName: "E2B",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await vi.waitFor(() => {
expect(mockPluginsApi.list).toHaveBeenCalled();
});
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]'));
expect(pluginLinks).toHaveLength(0);
});
});
+18 -3
View File
@@ -1,17 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
import type { PluginRecord } from "@paperclipai/shared";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem";
/**
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
* settings page — `PluginSettings` redirects them to the Environments page —
* so a sidebar entry would lead nowhere useful. Filter them out here. Plugins
* that mix a sandbox provider with other contributions still appear.
*/
function isSandboxProviderOnly(plugin: PluginRecord): boolean {
const drivers = plugin.manifestJson.environmentDrivers ?? [];
if (drivers.length === 0) return false;
return drivers.every((d) => d.kind === "sandbox_provider");
}
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const sidebarPlugins = (plugins ?? []).filter((p) => !isSandboxProviderOnly(p));
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
@@ -29,10 +44,9 @@ export function InstanceSidebar() {
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
{(plugins ?? []).length > 0 ? (
{sidebarPlugins.length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (
{sidebarPlugins.map((plugin) => (
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
@@ -51,6 +65,7 @@ export function InstanceSidebar() {
))}
</div>
) : null}
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
</div>
</nav>
</aside>
+101 -3
View File
@@ -3,10 +3,14 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
@@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
retryScheduledRetryNow: retryNowMock,
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
const baseRetry: IssueScheduledRetry = {
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "agent-1",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: "2026-04-19T20:00:00.000Z",
scheduledRetryAttempt: 1,
scheduledRetryReason: "max_turns_continuation",
retryExhaustedReason: null,
error: null,
errorCode: null,
};
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
return {
outcome,
message:
outcome === "promoted"
? "Promoted scheduled retry"
: outcome === "already_promoted"
? "Scheduled retry already promoted"
: outcome === "no_scheduled_retry"
? "No scheduled retry"
: "Promotion suppressed by gate",
scheduledRetry:
outcome === "promoted" || outcome === "already_promoted"
? { ...baseRetry, status: "queued" as const }
: null,
};
}
beforeEach(() => {
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
retryNowMock.mockReset();
});
afterEach(() => {
if (root) {
@@ -27,13 +77,22 @@ afterEach(() => {
root = null;
container?.remove();
container = null;
dateNowSpy?.mockRestore();
dateNowSpy = null;
});
function withProviders(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
return (
<MemoryRouter>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
<QueryClientProvider client={client}>
<ToastProvider>{node}</ToastProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
@@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Retry now");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("Corrective wake scheduled in 1d");
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
expect(button).not.toBeNull();
expect(button!.textContent ?? "").toContain("Retry now");
await act(async () => {
button!.click();
await Promise.resolve();
});
await vi.waitFor(() => {
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
expect(button!.textContent ?? "").toContain("Promoted");
expect(button!.disabled).toBe(true);
});
});
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
<IssueBlockedNotice
+86 -1
View File
@@ -2,12 +2,17 @@ import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, Flag } from "lucide-react";
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
deriveActiveRecoveryDisplayState,
@@ -34,22 +39,96 @@ function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
);
}
function SuccessfulRunRetryNowControl({
issueId,
scheduledRetry,
}: {
issueId: string;
scheduledRetry: IssueScheduledRetry;
}) {
const retryNow = useRetryNowMutation(issueId);
const dueAtIso = scheduledRetry.scheduledRetryAt
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
: null;
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
const scheduleLabel = relative === "now"
? "due now"
: relative
? `scheduled ${relative}`
: "scheduled";
const success = retryNow.isSuccess
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
return (
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
onClick={() => retryNow.mutate()}
disabled={retryNow.isPending || success}
data-testid="issue-next-step-retry-now"
>
{retryNow.isPending ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Retrying...
</span>
) : success ? (
<span className="inline-flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Retry now
</span>
)}
</Button>
</div>
<RetryErrorBand
error={retryNow.lastError}
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
onRetry={() => {
retryNow.reset();
retryNow.mutate();
}}
/>
</div>
);
}
export function IssueBlockedNotice({
issueId,
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
scheduledRetry,
agentName,
}: {
issueId?: string | null;
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
agentName?: string | null;
}) {
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const successfulRunRetryNow = showSuccessfulRunHandoff
&& issueId
&& scheduledRetry?.status === "scheduled_retry"
? { issueId, scheduledRetry }
: null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -162,6 +241,12 @@ export function IssueBlockedNotice({
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
{successfulRunRetryNow ? (
<SuccessfulRunRetryNowControl
issueId={successfulRunRetryNow.issueId}
scheduledRetry={successfulRunRetryNow.scheduledRetry}
/>
) : null}
</>
) : null}
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
+7
View File
@@ -38,6 +38,7 @@ import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState,
IssueWorkMode,
} from "@paperclipai/shared";
@@ -296,9 +297,11 @@ interface IssueChatThreadProps {
timelineEvents?: IssueTimelineEvent[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
issueId?: string | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@@ -3617,9 +3620,11 @@ export function IssueChatThread({
timelineEvents = [],
liveRuns = [],
activeRun = null,
issueId = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
scheduledRetry = null,
recoveryAction = null,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction = false,
@@ -4299,10 +4304,12 @@ export function IssueChatThread({
/>
) : null}
<IssueBlockedNotice
issueId={issueId}
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
scheduledRetry={scheduledRetry}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
@@ -38,6 +38,9 @@ function createHandoffDocument(): IssueDocument {
createdByUserId: null,
updatedByAgentId: "agent-1",
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
createdAt: new Date("2026-04-19T12:00:00.000Z"),
updatedAt: new Date("2026-04-19T12:05:00.000Z"),
};
@@ -15,6 +15,8 @@ const mockIssuesApi = vi.hoisted(() => ({
listDocumentRevisions: vi.fn(),
restoreDocumentRevision: vi.fn(),
upsertDocument: vi.fn(),
lockDocument: vi.fn(),
unlockDocument: vi.fn(),
deleteDocument: vi.fn(),
getDocument: vi.fn(),
}));
@@ -178,6 +180,9 @@ function createIssueDocument(overrides: Partial<IssueDocument> = {}): IssueDocum
createdByUserId: "user-1",
updatedByAgentId: null,
updatedByUserId: "user-1",
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
...overrides,
@@ -306,6 +311,105 @@ describe("IssueDocumentsSection", () => {
queryClient.clear();
});
it("locks documents from the document header action", async () => {
const unlockedDocument = createIssueDocument({
body: "Draftable plan body",
lockedAt: null,
});
const lockedDocument = createIssueDocument({
body: "Draftable plan body",
lockedAt: new Date("2026-03-31T12:06:00.000Z"),
lockedByUserId: "user-1",
updatedAt: new Date("2026-03-31T12:06:00.000Z"),
});
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments
.mockResolvedValueOnce([unlockedDocument])
.mockResolvedValue([lockedDocument]);
mockIssuesApi.lockDocument.mockResolvedValue(lockedDocument);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} canManageDocumentLocks />
</QueryClientProvider>,
);
});
await flush();
await flush();
const lockButton = container.querySelector('button[title="Lock document"]');
expect(lockButton).toBeTruthy();
await act(async () => {
lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.lockDocument).toHaveBeenCalledWith("issue-1", "plan");
expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy();
await act(async () => {
root.unmount();
});
queryClient.clear();
});
it("hides direct edit and delete actions for locked documents", async () => {
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments.mockResolvedValue([
createIssueDocument({
body: "Locked plan body",
lockedAt: new Date("2026-03-31T12:06:00.000Z"),
lockedByUserId: "user-1",
}),
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments canManageDocumentLocks />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.textContent).toContain("Locked plan body");
expect(container.textContent).not.toContain("Edit document");
expect(container.textContent).not.toContain("Delete document");
expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy();
await act(async () => {
root.unmount();
});
queryClient.clear();
});
it("shows the restored document body immediately after a revision restore", async () => {
const blankLatestDocument = createIssueDocument({
body: "",
+83 -19
View File
@@ -32,7 +32,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react";
import { DocumentDiffModal } from "./DocumentDiffModal";
type DraftState = {
@@ -91,6 +91,10 @@ function isDocumentConflictError(error: unknown) {
return error instanceof ApiError && error.status === 409;
}
function isLockedDocumentError(error: unknown) {
return error instanceof ApiError && error.status === 409 && error.message === "Document is locked";
}
function downloadDocumentFile(key: string, body: string) {
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -128,6 +132,9 @@ function toDocumentSummary(document: IssueDocument) {
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
lockedAt: document.lockedAt,
lockedByAgentId: document.lockedByAgentId,
lockedByUserId: document.lockedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
@@ -136,6 +143,7 @@ function toDocumentSummary(document: IssueDocument) {
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
canManageDocumentLocks = false,
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -146,6 +154,7 @@ export function IssueDocumentsSection({
}: {
issue: Issue;
canDeleteDocuments: boolean;
canManageDocumentLocks?: boolean;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -279,6 +288,22 @@ export function IssueDocumentsSection({
},
});
const setDocumentLock = useMutation({
mutationFn: ({ key, locked }: { key: string; locked: boolean }) =>
locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key),
onSuccess: (document) => {
syncDocumentCaches(document);
setDraft((current) => current?.key === document.key ? null : current);
setDocumentConflict((current) => current?.key === document.key ? null : current);
resetAutosaveState();
setError(null);
invalidateIssueDocuments();
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to update document lock");
},
});
const sortedDocuments = useMemo(() => {
return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => {
if (a.key === "plan" && b.key !== "plan") return -1;
@@ -442,6 +467,12 @@ export function IssueDocumentsSection({
}
return true;
} catch (err) {
if (isLockedDocumentError(err)) {
setError("Document is locked. Unlock it before editing.");
resetAutosaveState();
invalidateIssueDocuments();
return false;
}
if (isDocumentConflictError(err)) {
try {
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
@@ -563,6 +594,15 @@ export function IssueDocumentsSection({
setError(null);
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
const toggleDocumentLock = useCallback((doc: IssueDocument, locked: boolean) => {
if (!canManageDocumentLocks || setDocumentLock.isPending) return;
if (locked && (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft))) {
setError("Save or cancel local changes before changing the document lock.");
return;
}
setDocumentLock.mutate({ key: doc.key, locked });
}, [canManageDocumentLocks, documentConflict, draft, setDocumentLock]);
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (autosaveDebounceRef.current) {
@@ -789,8 +829,9 @@ export function IssueDocumentsSection({
<div className="space-y-3">
{sortedDocuments.map((doc) => {
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
const isLocked = Boolean(doc.lockedAt);
const activeDraft = !isLocked && draft?.key === doc.key && !draft.isNew ? draft : null;
const activeConflict = !isLocked && documentConflict?.key === doc.key ? documentConflict : null;
const isFolded = foldedDocumentKeys.includes(doc.key);
const rawRevisionHistory = getDocumentRevisions(doc.key);
const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory);
@@ -809,6 +850,7 @@ export function IssueDocumentsSection({
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
const lockActionPending = setDocumentLock.isPending && setDocumentLock.variables?.key === doc.key;
return (
<div
@@ -898,6 +940,26 @@ export function IssueDocumentsSection({
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
{canManageDocumentLocks ? (
<Button
variant="ghost"
size="icon-xs"
className={cn(
"text-muted-foreground transition-colors",
isLocked && "text-amber-300 hover:text-amber-200",
)}
title={isLocked ? "Unlock document" : "Lock document"}
aria-label={isLocked ? `Unlock ${doc.key} document` : `Lock ${doc.key} document`}
onClick={() => toggleDocumentLock(doc, !isLocked)}
disabled={lockActionPending}
>
{isLocked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
</Button>
) : isLocked ? (
<span title="Locked document" aria-label="Locked document" className="inline-flex h-6 w-6 items-center justify-center text-amber-300">
<Lock className="h-3.5 w-3.5" />
</span>
) : null}
<Button
variant="ghost"
size="icon-xs"
@@ -926,13 +988,13 @@ export function IssueDocumentsSection({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isHistoricalPreview ? (
{!isHistoricalPreview && !isLocked ? (
<DropdownMenuItem onClick={() => beginEdit(doc.key)}>
<FilePenLine className="h-3.5 w-3.5" />
Edit document
</DropdownMenuItem>
) : null}
{!isHistoricalPreview ? <DropdownMenuSeparator /> : null}
{!isHistoricalPreview && !isLocked ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
>
@@ -945,8 +1007,8 @@ export function IssueDocumentsSection({
View diff
</DropdownMenuItem>
) : null}
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
{canDeleteDocuments ? (
{canDeleteDocuments && !isLocked ? <DropdownMenuSeparator /> : null}
{canDeleteDocuments && !isLocked ? (
<DropdownMenuItem
variant="destructive"
onClick={() => setConfirmDeleteKey(doc.key)}
@@ -997,18 +1059,20 @@ export function IssueDocumentsSection({
>
Return to latest
</Button>
<Button
size="sm"
onClick={() => restoreDocumentRevision.mutate({
key: doc.key,
revisionId: selectedHistoricalRevision.id,
})}
disabled={restoreDocumentRevision.isPending}
>
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
? "Restoring..."
: "Restore this revision"}
</Button>
{!isLocked ? (
<Button
size="sm"
onClick={() => restoreDocumentRevision.mutate({
key: doc.key,
revisionId: selectedHistoricalRevision.id,
})}
disabled={restoreDocumentRevision.isPending}
>
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
? "Restoring..."
: "Restore this revision"}
</Button>
) : null}
</div>
</div>
</div>
@@ -121,6 +121,22 @@ async function flush() {
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
@@ -476,6 +492,60 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("searches all company issues when adding a blocker", async () => {
const onUpdate = vi.fn();
const loadedIssue = createIssue({ id: "issue-3", identifier: "PAP-3", title: "Loaded issue", status: "todo" });
const remoteIssue = createIssue({ id: "issue-99", identifier: "PAP-99", title: "Remote blocker", status: "in_progress" });
mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => {
if (filters?.q === "remote") return Promise.resolve([remoteIssue]);
return Promise.resolve([loadedIssue]);
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const searchInput = container.querySelector('input[aria-label="Search issues to add as blockers"]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "remote");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "remote", limit: 50 });
expect(container.textContent).toContain("PAP-99 Remote blocker");
expect(container.textContent).not.toContain("PAP-3 Loaded issue");
});
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-99 Remote blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-99"] });
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
+40 -14
View File
@@ -145,6 +145,8 @@ interface IssuePropertiesProps {
inline?: boolean;
}
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5">
@@ -405,6 +407,7 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -443,10 +446,21 @@ export function IssueProperties({
enabled: !!companyId,
});
const { data: allIssues } = useQuery({
const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({
queryKey: queryKeys.issues.list(companyId!),
queryFn: () => issuesApi.list(companyId!),
enabled: !!companyId && (blockedByOpen || parentOpen),
enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)),
});
const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({
queryKey: companyId
? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT)
: ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT],
queryFn: () => issuesApi.list(companyId!, {
q: normalizedBlockedBySearch,
limit: ISSUE_BLOCKER_SEARCH_LIMIT,
}),
enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0,
});
const createLabel = useMutation({
@@ -1648,27 +1662,28 @@ export function IssueProperties({
</>
);
const blockingIssues = issue.blocks ?? [];
const blockerOptions = (allIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
.filter((candidate) => {
if (!blockedBySearch.trim()) return true;
const query = blockedBySearch.toLowerCase();
return (
(candidate.identifier ?? "").toLowerCase().includes(query) ||
candidate.title.toLowerCase().includes(query)
);
})
.sort((a, b) => {
const blockerSearchActive = normalizedBlockedBySearch.length > 0;
const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues;
const blockerOptions = (blockerSourceIssues ?? [])
.filter((candidate) => candidate.id !== issue.id);
if (!blockerSearchActive) {
blockerOptions.sort((a, b) => {
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
return aLabel.localeCompare(bLabel);
});
}
const blockerOptionsLoading = blockedByOpen && (
blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues
);
const toggleBlockedBy = (blockedByIssueId: string) => {
const nextBlockedByIds = blockedByIds.includes(blockedByIssueId)
? blockedByIds.filter((candidate) => candidate !== blockedByIssueId)
: [...blockedByIds, blockedByIssueId];
onUpdate({ blockedByIssueIds: nextBlockedByIds });
setBlockedByOpen(false);
setBlockedBySearch("");
};
const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
@@ -1682,6 +1697,7 @@ export function IssueProperties({
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
autoFocus={!inline}
aria-label="Search issues to add as blockers"
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
@@ -1689,7 +1705,11 @@ export function IssueProperties({
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
blockedByIds.length === 0 && "bg-accent",
)}
onClick={() => onUpdate({ blockedByIssueIds: [] })}
onClick={() => {
onUpdate({ blockedByIssueIds: [] });
setBlockedByOpen(false);
setBlockedBySearch("");
}}
>
No blockers
</button>
@@ -1709,9 +1729,15 @@ export function IssueProperties({
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
</span>
{selected && <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
</button>
);
})}
{blockerOptionsLoading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">Searching issues...</div>
) : blockerOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matching issues.</div>
) : null}
</div>
</>
);
@@ -165,19 +165,20 @@ describe("IssueRecoveryActionCard", () => {
expect(node.textContent).toContain("Resolved as restored");
});
it("calls resolve with done and does not offer delegated recovery", () => {
it("calls resolve with todo and does not offer delegated recovery", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).not.toContain("Mark blocked");
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Try again")) ?? null);
expect(onResolve).toHaveBeenCalledWith("done");
expect(onResolve).toHaveBeenCalledWith("todo");
});
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
@@ -186,6 +187,7 @@ describe("IssueRecoveryActionCard", () => {
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).toContain("Send for review");
expect(document.body.textContent).toContain("False positive, done");
@@ -25,6 +25,7 @@ export type RecoveryCardCardState = RecoveryDisplayState;
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
export type RecoveryResolveOutcome =
| "todo"
| "done"
| "in_review"
| "false_positive_done"
@@ -292,6 +293,11 @@ const RESOLVE_OPTIONS: Array<{
destructive?: boolean;
boardOnly?: boolean;
}> = [
{
outcome: "todo",
label: "Try again",
description: "Dismiss recovery and return the source issue to todo.",
},
{
outcome: "done",
label: "Mark issue done",
+2 -2
View File
@@ -228,7 +228,7 @@ describe("IssueRow", () => {
});
});
it("renders planning mode marker for planning work mode issues", () => {
it("does not render a planning mode marker for planning work mode issues", () => {
const root = createRoot(container);
act(() => {
@@ -237,7 +237,7 @@ describe("IssueRow", () => {
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.textContent).toContain("Planning");
expect(link?.textContent).not.toContain("Planning");
act(() => {
root.unmount();
+2 -10
View File
@@ -85,14 +85,6 @@ export function IssueRow({
{checklistStepNumber}.
</span>
) : null;
const planningModeIndicator = issue.workMode === "planning" ? (
<span
className="ml-1.5 inline-flex shrink-0 items-center rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300"
title="This issue is in planning mode."
>
Planning
</span>
) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
@@ -126,7 +118,6 @@ export function IssueRow({
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
{productivityReviewIndicator}
{planningModeIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</span>
@@ -153,7 +144,6 @@ export function IssueRow({
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
{planningModeIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</>
@@ -181,6 +171,7 @@ export function IssueRow({
{showUnreadDot ? (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -210,6 +201,7 @@ export function IssueRow({
) : onArchive ? (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
+4
View File
@@ -16,6 +16,8 @@ import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
import { describeRunRetryState } from "../lib/runRetryState";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge";
type IssueRunLedgerProps = {
issueId: string;
@@ -693,6 +695,7 @@ export function IssueRunLedgerContent({
const continuation = continuationLabel(run);
const retryState = describeRunRetryState(run);
const agentName = compactAgentName(run, agentMap);
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
return (
<article
key={`run:${run.runId}`}
@@ -773,6 +776,7 @@ export function IssueRunLedgerContent({
</span>
);
})()}
{sourceResolvedFold ? <SourceResolvedFoldBadge /> : null}
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
</div>
+115 -1
View File
@@ -123,7 +123,17 @@ vi.mock("./IssueRow", () => ({
}));
vi.mock("./KanbanBoard", () => ({
KanbanBoard: (props: { issues: Issue[] }) => {
KANBAN_BOARD_HIGH_VOLUME_THRESHOLD: 100,
KANBAN_COLD_STATUSES: ["backlog", "done", "cancelled"],
KANBAN_COLUMN_DEFAULT_PAGE_SIZE: 10,
KANBAN_COLUMN_PAGE_SIZE_OPTIONS: [10, 25, 50],
KanbanBoard: (props: {
issues: Issue[];
compactCards?: boolean;
collapsedStatuses?: string[];
initialVisibleCount?: number;
revealIncrement?: number;
}) => {
mockKanbanBoard(props);
return (
<div data-testid="kanban-board">
@@ -1011,6 +1021,110 @@ describe("IssuesList", () => {
});
});
it("uses compact cards and collapsed cold lanes for high-volume boards", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ viewMode: "board" }),
);
const backlogIssues = Array.from({ length: 101 }, (_, index) =>
createIssue({
id: `issue-backlog-${index + 1}`,
identifier: `PAP-${index + 1}`,
title: `Backlog issue ${index + 1}`,
status: "backlog",
}),
);
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve(backlogIssues);
return Promise.resolve([]);
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
compactCards: true,
collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]),
initialVisibleCount: 10,
revealIncrement: 10,
}));
});
act(() => {
root.unmount();
});
});
it("lets board users choose the per-column page size", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ viewMode: "board" }),
);
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue({ id: "issue-page-size", title: "Page size issue" })]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
initialVisibleCount: 10,
revealIncrement: 10,
}));
});
const pageSizeButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.getAttribute("title") === "Cards per column",
);
expect(pageSizeButton).toBeTruthy();
act(() => {
pageSizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
let option25: HTMLButtonElement | undefined;
await waitForAssertion(() => {
option25 = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("25 per column"),
);
expect(option25).toBeTruthy();
});
act(() => {
option25?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await waitForAssertion(() => {
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
initialVisibleCount: 25,
revealIncrement: 25,
}));
});
expect(localStorage.getItem("paperclip:test-issues:company-1")).toContain("\"boardColumnPageSize\":25");
act(() => {
root.unmount();
});
});
it("shows a refinement hint when a board column hits its server cap", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
+143 -5
View File
@@ -60,8 +60,15 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2 } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2, ChevronsDownUp, PanelTopClose, RotateCcw, ListCollapse } from "lucide-react";
import {
KanbanBoard,
KANBAN_BOARD_HIGH_VOLUME_THRESHOLD,
KANBAN_COLD_STATUSES,
KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
KANBAN_COLUMN_PAGE_SIZE_OPTIONS,
type KanbanColumnPageSize,
} from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
@@ -110,6 +117,9 @@ const progressSegmentClasses: Record<IssueStatus, string> = {
/* ── View state ── */
export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow";
export type BoardCardDensity = "auto" | "compact" | "comfortable";
export type BoardColdLaneMode = "auto" | "collapsed" | "expanded";
export type BoardColumnPageSize = KanbanColumnPageSize;
export type IssueViewState = IssueFilterState & {
sortField: IssueSortField;
@@ -119,6 +129,9 @@ export type IssueViewState = IssueFilterState & {
nestingEnabled: boolean;
collapsedGroups: string[];
collapsedParents: string[];
boardCardDensity: BoardCardDensity;
boardColdLaneMode: BoardColdLaneMode;
boardColumnPageSize: BoardColumnPageSize;
};
const defaultViewState: IssueViewState = {
@@ -130,14 +143,38 @@ const defaultViewState: IssueViewState = {
nestingEnabled: true,
collapsedGroups: [],
collapsedParents: [],
boardCardDensity: "auto",
boardColdLaneMode: "auto",
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
};
function normalizeBoardCardDensity(value: unknown): BoardCardDensity {
return value === "compact" || value === "comfortable" || value === "auto" ? value : "auto";
}
function normalizeBoardColdLaneMode(value: unknown): BoardColdLaneMode {
return value === "collapsed" || value === "expanded" || value === "auto" ? value : "auto";
}
function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize {
return KANBAN_COLUMN_PAGE_SIZE_OPTIONS.includes(value as BoardColumnPageSize)
? value as BoardColumnPageSize
: KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
}
function getViewState(key: string): IssueViewState {
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) };
return {
...defaultViewState,
...parsed,
...normalizeIssueFilterState(parsed),
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
};
}
} catch { /* ignore */ }
return { ...defaultViewState };
@@ -1007,6 +1044,22 @@ export function IssuesList({
});
const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
const boardCompactCards =
viewState.boardCardDensity === "compact"
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
const boardCollapsedStatuses = useMemo(
() =>
viewState.boardColdLaneMode === "collapsed"
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
? [...KANBAN_COLD_STATUSES]
: [],
[boardHighVolume, viewState.boardColdLaneMode],
);
const boardDensityCustomized =
viewState.boardCardDensity !== "auto"
|| viewState.boardColdLaneMode !== "auto"
|| viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
@@ -1197,7 +1250,9 @@ export function IssuesList({
}
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") {
const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey) ?? null;
const representativeIssue = group?.items.find((issue) =>
issue.executionWorkspaceId === groupKey || issue.projectWorkspaceId === groupKey,
) ?? null;
const executionWorkspace = executionWorkspaceById.get(groupKey);
if (executionWorkspace) {
defaults.executionWorkspaceId = groupKey;
@@ -1324,6 +1379,83 @@ export function IssuesList({
</Button>
)}
{viewState.viewMode === "board" && (
<>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", boardCompactCards && "bg-accent")}
onClick={() => updateView({ boardCardDensity: boardCompactCards ? "comfortable" : "compact" })}
title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", boardCollapsedStatuses.length > 0 && "bg-accent")}
onClick={() => updateView({ boardColdLaneMode: boardCollapsedStatuses.length > 0 ? "expanded" : "collapsed" })}
title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}
>
<PanelTopClose className="h-3.5 w-3.5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 shrink-0 gap-1.5 px-2",
viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE && "bg-accent",
)}
title="Cards per column"
>
<ListCollapse className="h-3.5 w-3.5" />
<span className="min-w-4 text-xs tabular-nums">{viewState.boardColumnPageSize}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-0">
<div className="p-2 space-y-0.5">
{KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => (
<button
key={pageSize}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
viewState.boardColumnPageSize === pageSize
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => updateView({ boardColumnPageSize: pageSize })}
>
<span>{pageSize} per column</span>
{viewState.boardColumnPageSize === pageSize && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => updateView({
boardCardDensity: "auto",
boardColdLaneMode: "auto",
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
})}
disabled={!boardDensityCustomized}
title="Reset board density"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</>
)}
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
@@ -1454,6 +1586,10 @@ export function IssuesList({
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
compactCards={boardCompactCards}
collapsedStatuses={boardCollapsedStatuses}
initialVisibleCount={viewState.boardColumnPageSize}
revealIncrement={viewState.boardColumnPageSize}
onUpdateIssue={onUpdateIssue}
/>
) : (
@@ -1570,6 +1706,7 @@ export function IssuesList({
<button
key={firstVisibleBlockerChip.blockerId}
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -1645,7 +1782,7 @@ export function IssuesList({
className={isMutedIssue ? "opacity-70" : undefined}
mobileLeading={
hasChildren ? (
<button type="button" onClick={toggleCollapse}>
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
@@ -1659,6 +1796,7 @@ export function IssuesList({
{hasChildren ? (
<button
type="button"
data-slot="icon-button"
className="hidden shrink-0 items-center sm:inline-flex"
onClick={toggleCollapse}
>
+185 -1
View File
@@ -8,6 +8,34 @@ import { JsonSchemaForm } from "./JsonSchemaForm";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// SecretBindingPicker pulls in CompanyContext + react-query. Stub it so we can
// exercise SecretField in isolation. The stub renders a select with the same
// onChange contract as the real picker.
vi.mock("./SecretBindingPicker", () => ({
SecretBindingPicker: ({
value,
onChange,
disabled,
}: {
value: { secretId: string } | null;
onChange: (next: { secretId: string } | null) => void;
disabled?: boolean;
}) => (
<select
data-testid="secret-binding-picker"
value={value?.secretId ?? ""}
onChange={(event) => {
const next = event.target.value;
onChange(next ? { secretId: next } : null);
}}
disabled={disabled}
>
<option value="">none</option>
<option value="11111111-1111-4111-8111-111111111111">existing-secret</option>
</select>
),
}));
describe("JsonSchemaForm secret-ref rendering", () => {
let container: HTMLDivElement;
@@ -22,7 +50,7 @@ describe("JsonSchemaForm secret-ref rendering", () => {
vi.clearAllMocks();
});
it("renders multiline secret-ref fields as textareas", async () => {
it("renders multiline secret-ref fields as textareas alongside the picker", async () => {
const root = createRoot(container);
await act(async () => {
@@ -44,6 +72,9 @@ describe("JsonSchemaForm secret-ref rendering", () => {
);
});
// The picker is always rendered, and a non-UUID raw value auto-opens the
// textarea fallback.
expect(container.querySelector('[data-testid="secret-binding-picker"]')).not.toBeNull();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.querySelector('input[type="password"]')).toBeNull();
@@ -51,4 +82,157 @@ describe("JsonSchemaForm secret-ref rendering", () => {
root.unmount();
});
});
it("renders the picker and hides the raw input when the value is a UUID secret ref", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<JsonSchemaForm
schema={{
type: "object",
properties: {
apiKey: {
type: "string",
format: "secret-ref",
},
},
}}
values={{ apiKey: "11111111-1111-4111-8111-111111111111" }}
onChange={() => {}}
/>,
);
});
expect(
container.querySelector('[data-testid="secret-binding-picker"]'),
).not.toBeNull();
// No raw input or textarea is visible while a secret is bound.
expect(container.querySelector('input[type="password"]')).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
await act(async () => {
root.unmount();
});
});
it("writes the secret id to form values when the picker selects an existing secret", async () => {
const root = createRoot(container);
const onChange = vi.fn();
await act(async () => {
root.render(
<JsonSchemaForm
schema={{
type: "object",
properties: {
apiKey: {
type: "string",
format: "secret-ref",
},
},
}}
values={{ apiKey: "" }}
onChange={onChange}
/>,
);
});
const picker = container.querySelector<HTMLSelectElement>(
'[data-testid="secret-binding-picker"]',
);
expect(picker).not.toBeNull();
const setSelectValue = Object.getOwnPropertyDescriptor(
window.HTMLSelectElement.prototype,
"value",
)?.set;
expect(setSelectValue).toBeTruthy();
await act(async () => {
setSelectValue!.call(picker!, "11111111-1111-4111-8111-111111111111");
picker!.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith({
apiKey: "11111111-1111-4111-8111-111111111111",
});
await act(async () => {
root.unmount();
});
});
it("auto-opens the raw input when a raw value arrives after mount", async () => {
const root = createRoot(container);
const schema = {
type: "object" as const,
properties: {
apiKey: {
type: "string" as const,
format: "secret-ref" as const,
},
},
};
// First render with empty value — picker visible, no raw input.
await act(async () => {
root.render(
<JsonSchemaForm schema={schema} values={{ apiKey: "" }} onChange={() => {}} />,
);
});
expect(container.querySelector('input[type="password"]')).toBeNull();
// Parent fills in a previously-saved raw value (the async load case).
await act(async () => {
root.render(
<JsonSchemaForm
schema={schema}
values={{ apiKey: "loaded-from-api" }}
onChange={() => {}}
/>,
);
});
const input = container.querySelector<HTMLInputElement>('input[type="password"]');
expect(input).not.toBeNull();
expect(input?.value).toBe("loaded-from-api");
await act(async () => {
root.unmount();
});
});
it("keeps the password fallback for short raw values", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<JsonSchemaForm
schema={{
type: "object",
properties: {
apiKey: {
type: "string",
format: "secret-ref",
},
},
}}
values={{ apiKey: "raw-value" }}
onChange={() => {}}
/>,
);
});
const input = container.querySelector<HTMLInputElement>(
'input[type="password"]',
);
expect(input).not.toBeNull();
expect(input?.value).toBe("raw-value");
await act(async () => {
root.unmount();
});
});
});
+157 -78
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
@@ -7,6 +7,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { isUuidLike } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -20,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
// ---------------------------------------------------------------------------
// Constants
@@ -467,7 +469,10 @@ const EnumField = React.memo(({
EnumField.displayName = "EnumField";
/**
* Specialized field for secret-ref values, providing a toggleable password input.
* Specialized field for secret-ref values. Renders a picker for existing
* company secrets plus a raw-value fallback. A UUID-shaped value is treated
* as a bound secret reference; anything else is a raw value that the server
* converts to a stored secret on save.
*/
const SecretField = React.memo(({
value,
@@ -492,94 +497,168 @@ const SecretField = React.memo(({
}) => {
const [isVisible, setIsVisible] = useState(false);
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
const stringValue = typeof value === "string" ? value : "";
const trimmed = stringValue.trim();
const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed);
const hasRawValue = stringValue.length > 0 && !isBoundToSecret;
const [showRawInput, setShowRawInput] = useState(hasRawValue);
// Keep the raw-input panel open when the parent loads a raw value after
// mount (e.g. an environment-config form rendering with empty defaults
// before its API response arrives). We only promote to `true` here; manual
// toggles off are still preserved as long as `hasRawValue` is false.
useEffect(() => {
if (hasRawValue) setShowRawInput(true);
}, [hasRawValue]);
const bindingValue: SecretBindingValue | null = isBoundToSecret
? { secretId: trimmed }
: null;
const handlePickerChange = useCallback(
(next: SecretBindingValue | null) => {
if (next) {
onChange(next.secretId);
setShowRawInput(false);
setIsVisible(false);
} else {
onChange("");
}
},
[onChange],
);
const rawInput = isTextArea ? (
<div className="relative">
{isVisible ? (
<Textarea
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="min-h-[140px] pr-10 font-mono text-xs"
aria-invalid={!!error}
/>
) : (
<Textarea
// Render a placeholder summary instead of the secret content while
// hidden. This avoids exposing multi-line secrets (e.g. SSH
// private keys) on screen-shares; clicking the eye toggle reveals
// the editable textarea above.
value={
stringValue.length === 0
? ""
: `Sensitive — ${stringValue.length} characters hidden. Click the eye to reveal.`
}
readOnly
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
aria-invalid={!!error}
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
onClick={() => setIsVisible(!isVisible)}
disabled={disabled}
>
{isVisible ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{isVisible ? "Hide secret" : "Show secret"}
</span>
</Button>
</div>
) : (
<div className="relative">
<Input
type={isVisible ? "text" : "password"}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="pr-10"
aria-invalid={!!error}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setIsVisible(!isVisible)}
disabled={disabled}
>
{isVisible ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{isVisible ? "Hide secret" : "Show secret"}
</span>
</Button>
</div>
);
return (
<FieldWrapper
label={label}
description={
description ||
"This secret is stored securely via the Paperclip secret provider."
"Pick an existing company secret, or paste a raw value (Paperclip will store it as a secret on save)."
}
required={isRequired}
error={error}
disabled={disabled}
>
{isTextArea ? (
<div className="relative">
{isVisible ? (
<Textarea
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="min-h-[140px] pr-10 font-mono text-xs"
aria-invalid={!!error}
/>
<div className="space-y-2">
<SecretBindingPicker
value={bindingValue}
onChange={handlePickerChange}
label=""
placeholder="Select an existing secret"
allowVersionSelector={false}
emptyHint="No active secrets yet. Create one or paste a raw value below."
disabled={disabled}
/>
{!isBoundToSecret ? (
showRawInput ? (
<div className="space-y-1">
{rawInput}
{!hasRawValue ? (
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => {
setShowRawInput(false);
setIsVisible(false);
}}
disabled={disabled}
>
Hide raw value input
</button>
) : null}
</div>
) : (
<Textarea
// Render a placeholder summary instead of the secret content while
// hidden. This avoids exposing multi-line secrets (e.g. SSH
// private keys) on screen-shares; clicking the eye toggle reveals
// the editable textarea above.
value={
String(value ?? "").length === 0
? ""
: `Sensitive — ${String(value ?? "").length} characters hidden. Click the eye to reveal.`
}
readOnly
placeholder={String(defaultValue ?? "")}
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setShowRawInput(true)}
disabled={disabled}
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
aria-invalid={!!error}
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
onClick={() => setIsVisible(!isVisible)}
disabled={disabled}
>
{isVisible ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{isVisible ? "Hide secret" : "Show secret"}
</span>
</Button>
</div>
) : (
<div className="relative">
<Input
type={isVisible ? "text" : "password"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="pr-10"
aria-invalid={!!error}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setIsVisible(!isVisible)}
disabled={disabled}
>
{isVisible ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{isVisible ? "Hide secret" : "Show secret"}
</span>
</Button>
</div>
)}
>
Or paste a raw value
</button>
)
) : null}
</div>
</FieldWrapper>
);
});
+198
View File
@@ -0,0 +1,198 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KanbanBoard, resolveKanbanTargetStatus } from "./KanbanBoard";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
disableIssueQuicklook: _disableIssueQuicklook,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
disableIssueQuicklook?: boolean;
}) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(index: number, status: IssueStatus): Issue {
return {
id: `issue-${status}-${index}`,
identifier: `PAP-${index}`,
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: `Issue ${index}`,
description: null,
status,
workMode: "standard",
priority: "medium",
assigneeAgentId: index === 1 ? "agent-1" : null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: index,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-05-05T00:00:00.000Z"),
updatedAt: new Date("2026-05-05T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: null,
isUnreadForMe: false,
};
}
function createIssues(count: number, status: IssueStatus): Issue[] {
return Array.from({ length: count }, (_, index) => createIssue(index + 1, status));
}
function renderBoard(
props: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] },
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const render = (nextProps: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] }) => {
act(() => {
root.render(
<KanbanBoard
agents={[{ id: "agent-1", name: "Codex" }]}
liveIssueIds={new Set(["issue-todo-1"])}
onUpdateIssue={vi.fn()}
{...nextProps}
/>,
);
});
};
render(props);
return { container, root, render };
}
describe("KanbanBoard", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("limits visible cards and reveals more cards per column", () => {
const { container } = renderBoard({
issues: createIssues(60, "todo"),
compactCards: true,
initialVisibleCount: 50,
revealIncrement: 50,
});
expect(container.textContent).toContain("Showing 50 of 60");
expect(container.textContent).toContain("Show 10 more");
expect(container.textContent).toContain("Issue 50");
expect(container.textContent).not.toContain("Issue 51");
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Show 10 more"),
);
expect(showMoreButton).toBeTruthy();
act(() => {
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Issue 60");
expect(container.textContent).not.toContain("Show 10 more");
});
it("resets visible counts when the column page size changes", () => {
const issues = createIssues(60, "todo");
const { container, render } = renderBoard({
issues,
initialVisibleCount: 50,
revealIncrement: 50,
});
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Show 10 more"),
);
expect(showMoreButton).toBeTruthy();
act(() => {
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Issue 60");
render({
issues,
initialVisibleCount: 10,
revealIncrement: 10,
});
expect(container.textContent).toContain("Showing 10 of 60");
expect(container.textContent).toContain("Show 10 more");
expect(container.textContent).toContain("Issue 10");
expect(container.textContent).not.toContain("Issue 11");
});
it("renders collapsed statuses as rails without cards", () => {
const { container } = renderBoard({
issues: createIssues(3, "done"),
collapsedStatuses: ["done"],
});
expect(container.textContent).toContain("Done");
expect(container.textContent).toContain("3");
expect(container.textContent).not.toContain("Issue 1");
});
it("keeps core issue signals in compact cards", () => {
const { container } = renderBoard({
issues: createIssues(1, "todo"),
compactCards: true,
});
expect(container.textContent).toContain("PAP-1");
expect(container.textContent).toContain("Issue 1");
expect(container.textContent).toContain("Codex");
expect(container.textContent).toContain("Live");
});
it("resolves drop targets from status rails and cards", () => {
const issues = [
createIssue(1, "todo"),
createIssue(2, "blocked"),
];
expect(resolveKanbanTargetStatus("done", issues)).toBe("done");
expect(resolveKanbanTargetStatus("issue-blocked-2", issues)).toBe("blocked");
expect(resolveKanbanTargetStatus("missing", issues)).toBeNull();
});
});
+113 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "@/lib/router";
import {
DndContext,
@@ -20,11 +20,19 @@ import {
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclipai/shared";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
const boardStatuses = [
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
export type KanbanColumnPageSize = (typeof KANBAN_COLUMN_PAGE_SIZE_OPTIONS)[number];
export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE: KanbanColumnPageSize = 10;
export const KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
export const KANBAN_COLUMN_REVEAL_INCREMENT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const;
export const boardStatuses = [
"backlog",
"todo",
"in_progress",
@@ -32,12 +40,19 @@ const boardStatuses = [
"blocked",
"done",
"cancelled",
];
] as const satisfies readonly IssueStatus[];
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
export function resolveKanbanTargetStatus(overId: string, issues: Issue[]): IssueStatus | null {
if ((boardStatuses as readonly string[]).includes(overId)) {
return overId as IssueStatus;
}
return issues.find((issue) => issue.id === overId)?.status ?? null;
}
interface Agent {
id: string;
name: string;
@@ -47,6 +62,10 @@ interface KanbanBoardProps {
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
compactCards?: boolean;
collapsedStatuses?: string[];
initialVisibleCount?: number;
revealIncrement?: number;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
@@ -57,15 +76,48 @@ function KanbanColumn({
issues,
agents,
liveIssueIds,
compactCards = false,
collapsed = false,
visibleCount,
revealIncrement,
onShowMore,
}: {
status: string;
status: IssueStatus;
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
compactCards?: boolean;
collapsed?: boolean;
visibleCount: number;
revealIncrement: number;
onShowMore: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
const isEmpty = issues.length === 0;
const visibleIssues = collapsed ? [] : issues.slice(0, visibleCount);
const hiddenCount = Math.max(issues.length - visibleIssues.length, 0);
const nextRevealCount = Math.min(revealIncrement, hiddenCount);
if (collapsed) {
return (
<div
ref={setNodeRef}
className={`flex min-h-[220px] w-[52px] shrink-0 flex-col items-center rounded-md border border-border bg-muted/20 px-1.5 py-2 transition-colors ${
isOver ? "bg-accent/50 ring-1 ring-primary/20" : ""
}`}
title={`${statusLabel(status)}: ${issues.length}`}
>
<StatusIcon status={status} />
<span className="mt-2 [writing-mode:vertical-rl] rotate-180 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{statusLabel(status)}
</span>
<span className="mt-auto rounded-full bg-background px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
{issues.length}
</span>
</div>
);
}
return (
<div className={`flex flex-col shrink-0 transition-[width,min-width] ${isEmpty && !isOver ? "min-w-[48px] w-[48px]" : "min-w-[260px] w-[260px]"}`}>
@@ -88,19 +140,35 @@ function KanbanColumn({
isOver ? "bg-accent/40" : "bg-muted/20"
}`}
>
{/* Hidden cards are intentionally excluded from sort targets until revealed. */}
<SortableContext
items={issues.map((i) => i.id)}
items={visibleIssues.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{issues.map((issue) => (
{visibleIssues.map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
agents={agents}
isLive={liveIssueIds?.has(issue.id)}
compact={compactCards}
/>
))}
</SortableContext>
{hiddenCount > 0 ? (
<button
type="button"
className="mt-1 flex w-full items-center justify-center rounded-md border border-dashed border-border bg-background/70 px-2 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
onClick={onShowMore}
>
Show {nextRevealCount} more
</button>
) : null}
{issues.length > 0 && (hiddenCount > 0 || issues.length >= visibleCount) ? (
<p className="px-1 pt-1 text-[11px] text-muted-foreground">
Showing {visibleIssues.length} of {issues.length}
</p>
) : null}
</div>
</div>
);
@@ -113,11 +181,13 @@ function KanbanCard({
agents,
isLive,
isOverlay,
compact = false,
}: {
issue: Issue;
agents?: Agent[];
isLive?: boolean;
isOverlay?: boolean;
compact?: boolean;
}) {
const {
attributes,
@@ -144,9 +214,11 @@ function KanbanCard({
style={style}
{...attributes}
{...listeners}
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
className={`rounded-md border bg-card cursor-grab active:cursor-grabbing transition-shadow ${
isDragging && !isOverlay ? "opacity-30" : ""
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"} ${
compact ? "p-2" : "p-2.5"
}`}
>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
@@ -157,7 +229,7 @@ function KanbanCard({
if (isDragging) e.preventDefault();
}}
>
<div className="flex items-start gap-1.5 mb-1.5">
<div className={`flex items-start gap-1.5 ${compact ? "mb-1" : "mb-1.5"}`}>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
@@ -172,14 +244,17 @@ function KanbanCard({
</span>
) : null}
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
{compact ? "Live" : null}
</span>
)}
</div>
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
<div className="flex items-center gap-2">
<p className={`${compact ? "mb-1.5 text-xs" : "mb-2 text-sm"} leading-snug line-clamp-2`}>{issue.title}</p>
<div className="flex items-center gap-2 min-w-0">
<PriorityIcon priority={issue.priority} />
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
@@ -203,16 +278,26 @@ export function KanbanBoard({
issues,
agents,
liveIssueIds,
compactCards = false,
collapsedStatuses = [],
initialVisibleCount = KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT,
revealIncrement = KANBAN_COLUMN_REVEAL_INCREMENT,
onUpdateIssue,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [visibleCountByStatus, setVisibleCountByStatus] = useState<Record<string, number>>({});
const collapsedStatusSet = useMemo(() => new Set(collapsedStatuses), [collapsedStatuses]);
useEffect(() => {
setVisibleCountByStatus({});
}, [initialVisibleCount, revealIncrement]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const columnIssues = useMemo(() => {
const grouped: Record<string, Issue[]> = {};
const grouped: Record<IssueStatus, Issue[]> = {} as Record<IssueStatus, Issue[]>;
for (const status of boardStatuses) {
grouped[status] = [];
}
@@ -244,17 +329,7 @@ export function KanbanBoard({
// Determine target status: the "over" could be a column id (status string)
// or another card's id. Find which column the "over" belongs to.
let targetStatus: string | null = null;
if (boardStatuses.includes(over.id as string)) {
targetStatus = over.id as string;
} else {
// It's a card - find which column it's in
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) {
targetStatus = targetIssue.status;
}
}
const targetStatus = resolveKanbanTargetStatus(over.id as string, issues);
if (targetStatus && targetStatus !== issue.status) {
onUpdateIssue(issueId, { status: targetStatus });
@@ -280,12 +355,22 @@ export function KanbanBoard({
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
compactCards={compactCards}
collapsed={collapsedStatusSet.has(status)}
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
revealIncrement={revealIncrement}
onShowMore={() => {
setVisibleCountByStatus((current) => ({
...current,
[status]: (current[status] ?? initialVisibleCount) + revealIncrement,
}));
}}
/>
))}
</div>
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
<KanbanCard issue={activeIssue} agents={agents} isOverlay compact={compactCards} />
) : null}
</DragOverlay>
</DndContext>
+111 -5
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Layout } from "./Layout";
@@ -27,6 +27,10 @@ const mockPluginSlots = vi.hoisted(() => ({
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockSidebarState = vi.hoisted(() => ({
sidebarOpen: true,
isMobile: false,
}));
let currentPathname = "/PAP/dashboard";
vi.mock("@/lib/router", () => ({
@@ -35,8 +39,11 @@ vi.mock("@/lib/router", () => ({
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => {
const firstSegment = currentPathname.split("/").filter(Boolean)[0];
return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" };
const [firstSegment, secondSegment] = currentPathname.split("/").filter(Boolean);
return {
companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP",
pluginRoutePath: firstSegment === "instance" ? undefined : secondSegment,
};
},
}));
@@ -161,10 +168,10 @@ vi.mock("../context/CompanyContext", () => ({
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
sidebarOpen: true,
sidebarOpen: mockSidebarState.sidebarOpen,
setSidebarOpen: mockSetSidebarOpen,
toggleSidebar: vi.fn(),
isMobile: false,
isMobile: mockSidebarState.isMobile,
}),
}));
@@ -201,6 +208,14 @@ vi.mock("../lib/main-content-focus", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
@@ -229,6 +244,8 @@ describe("Layout", () => {
});
mockPluginSlots.slots = [];
mockPluginSlotContexts.length = 0;
mockSidebarState.sidebarOpen = true;
mockSidebarState.isMobile = false;
});
afterEach(() => {
@@ -319,6 +336,40 @@ describe("Layout", () => {
});
});
it("renders a mobile company settings selector on company settings routes", async () => {
currentPathname = "/PAP/company/settings/secrets";
mockSidebarState.isMobile = true;
mockSidebarState.sidebarOpen = false;
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const selector = container.querySelector("select");
expect(selector).not.toBeNull();
expect(selector?.value).toBe("secrets");
expect(selector?.textContent).toContain("General");
expect(selector?.textContent).toContain("Environments");
expect(selector?.textContent).toContain("Cloud upstream");
expect(selector?.textContent).toContain("Members");
expect(selector?.textContent).toContain("Invites");
expect(selector?.textContent).toContain("Secrets");
await act(async () => {
root.unmount();
});
});
it("renders the instance settings sidebar on instance settings routes", async () => {
currentPathname = "/instance/settings/general";
const root = createRoot(container);
@@ -399,6 +450,61 @@ describe("Layout", () => {
});
});
it("keeps the route-scoped plugin sidebar on nested plugin page routes", async () => {
currentPathname = "/PAP/wiki/page/templates";
mockPluginSlots.slots = [
{
type: "page",
id: "wiki-page",
displayName: "Wiki Page",
exportName: "WikiPage",
routePath: "wiki",
pluginId: "plugin-1",
pluginKey: "wiki-plugin",
pluginDisplayName: "Wiki Plugin",
pluginVersion: "1.0.0",
},
{
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
routePath: "wiki",
pluginId: "plugin-1",
pluginKey: "wiki-plugin",
pluginDisplayName: "Wiki Plugin",
pluginVersion: "1.0.0",
},
];
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockUsePluginSlots).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
enabled: true,
}),
);
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.textContent).not.toContain("Main company nav");
await act(async () => {
root.unmount();
});
});
it("uses the route company context for plugin route sidebars on the first render", async () => {
currentPathname = "/ALT/wiki";
mockCompanyState.companies = [
+12 -3
View File
@@ -4,6 +4,7 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@@ -73,7 +74,10 @@ export function Layout() {
selectionSource,
setSelectedCompanyId,
} = useCompany();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const {
companyPrefix,
pluginRoutePath: matchedPluginRoutePath,
} = useParams<{ companyPrefix: string; pluginRoutePath?: string }>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
@@ -94,8 +98,8 @@ export function Layout() {
const hasUnknownCompanyPrefix =
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
const pluginRoutePath = useMemo(
() => getCompanyRouteSegment(location.pathname, companyPrefix),
[companyPrefix, location.pathname],
() => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix),
[companyPrefix, location.pathname, matchedPluginRoutePath],
);
const routeSidebarCompanyId = matchedCompany?.id ?? null;
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
@@ -421,6 +425,11 @@ export function Layout() {
)}
>
<BreadcrumbBar />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function renderMarkdown(children: string) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
});
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
flushSync(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("MarkdownBody code block interactions", () => {
it("toggles line wrapping for indented preformatted markdown blocks", () => {
const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
const pre = node.querySelector("pre");
const wrapButton = node.querySelector<HTMLButtonElement>(".paperclip-markdown-codeblock-wrap");
expect(pre?.style.whiteSpace).toBe("");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
click(wrapButton);
expect(pre?.style.whiteSpace).toBe("pre-wrap");
expect(pre?.style.overflowWrap).toBe("anywhere");
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
click(wrapButton);
expect(pre?.style.whiteSpace).toBe("");
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
});
});
+19 -2
View File
@@ -8,6 +8,7 @@ import {
buildAgentMentionHref,
buildIssueReferenceHref,
buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref,
buildUserMentionHref,
} from "@paperclipai/shared";
@@ -92,12 +93,12 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"');
});
it("renders user, agent, project, and skill mentions as chips", () => {
it("renders user, agent, project, skill, and routine mentions as chips", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")}) [/routine:Weekly review](${buildRoutineMentionHref("routine-123")})`}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
@@ -113,6 +114,8 @@ describe("MarkdownBody", () => {
expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"');
expect(html).toContain('href="/routines/routine-123"');
expect(html).toContain('data-mention-kind="routine"');
});
it("sanitizes unsafe javascript markdown links", () => {
@@ -446,11 +449,25 @@ describe("MarkdownBody", () => {
const html = renderMarkdown("```ts\nconst a = 1;\n```");
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-actions");
expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("position:static;opacity:1;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-copy");
expect(html).toContain('aria-label="Copy code"');
expect(html).toContain("lucide-copy");
});
it("renders code block actions for indented preformatted markdown blocks", () => {
const html = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("paperclip-markdown-codeblock-copy");
});
it("does not render a copy button on inline code", () => {
const html = renderMarkdown("Reference `inline-code` here.");
+96 -24
View File
@@ -1,6 +1,6 @@
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, Github } from "lucide-react";
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
@@ -84,6 +84,39 @@ const scrollableBlockStyle: React.CSSProperties = {
overflowX: "auto",
};
const codeBlockActionsStyle: React.CSSProperties = {
position: "absolute",
top: "0.4rem",
right: "0.4rem",
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
};
const codeBlockActionStyle: React.CSSProperties = {
position: "static",
opacity: 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "0.25rem",
minHeight: "1.55rem",
padding: "0.2rem 0.4rem",
borderRadius: "calc(var(--radius) - 4px)",
border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)",
backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)",
color: "var(--muted-foreground)",
fontSize: "0.7rem",
lineHeight: 1,
cursor: "pointer",
};
const codeBlockWrapActionStyle: React.CSSProperties = {
...codeBlockActionStyle,
width: "1.55rem",
paddingInline: 0,
};
const tableCellWrapStyle: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "normal",
@@ -364,6 +397,7 @@ function CodeBlock({
}) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -401,33 +435,69 @@ function CodeBlock({
}, 1500);
}, [children]);
const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
return (
<div className="paperclip-markdown-codeblock">
<div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
<pre
{...preProps}
ref={preRef}
style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}
style={{
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
...(wrapLines
? {
overflowX: "hidden",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
wordBreak: "break-word",
}
: null),
}}
>
{children}
</pre>
<button
type="button"
onClick={handleCopy}
aria-label="Copy code"
title={label}
className="paperclip-markdown-codeblock-copy"
data-copied={copied || undefined}
data-failed={failed || undefined}
<div
className="paperclip-markdown-codeblock-actions"
style={codeBlockActionsStyle}
data-active={copied || failed || wrapLines || undefined}
>
{copied && !failed ? (
<Check aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
)}
<span className="paperclip-markdown-codeblock-copy-label">{label}</span>
</button>
<button
type="button"
onClick={() => setWrapLines((value) => !value)}
aria-label={wrapLabel}
title={wrapLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
style={wrapLines
? {
...codeBlockWrapActionStyle,
borderColor: "color-mix(in oklab, var(--primary) 38%, transparent)",
color: "var(--primary)",
}
: codeBlockWrapActionStyle}
aria-pressed={wrapLines}
data-active={wrapLines || undefined}
>
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
aria-label="Copy code"
title={copyLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
style={codeBlockActionStyle}
data-copied={copied || undefined}
data-failed={failed || undefined}
>
{copied && !failed ? (
<Check aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
)}
<span className="paperclip-markdown-codeblock-action-label">{copyLabel}</span>
</button>
</div>
</div>
);
}
@@ -586,11 +656,13 @@ export function MarkdownBody({
? `/projects/${parsed.projectId}`
: parsed.kind === "issue"
? `/issues/${parsed.identifier}`
: parsed.kind === "skill"
? `/skills/${parsed.skillId}`
: parsed.kind === "user"
? "/company/settings/access"
: `/agents/${parsed.agentId}`;
: parsed.kind === "skill"
? `/skills/${parsed.skillId}`
: parsed.kind === "routine"
? `/routines/${parsed.routineId}`
: parsed.kind === "user"
? "/company/settings/access"
: `/agents/${parsed.agentId}`;
return (
<a
href={targetHref}
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
describe("MarkdownBody code block wrapping", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
});
afterEach(() => {
flushSync(() => root.unmount());
queryClient.clear();
container.remove();
});
it("toggles fenced code blocks between horizontal scroll and wrapped lines", () => {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody>{"```text\nlong line that can wrap when requested\n```"}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
});
const pre = container.querySelector("pre");
const actions = container.querySelector<HTMLDivElement>(
".paperclip-markdown-codeblock-actions",
);
const wrapButton = container.querySelector<HTMLButtonElement>(
".paperclip-markdown-codeblock-wrap",
);
expect(pre).not.toBeNull();
expect(actions).not.toBeNull();
expect(wrapButton).not.toBeNull();
expect(actions?.getAttribute("data-active")).toBeNull();
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
expect(pre?.style.overflowX).toBe("auto");
expect(pre?.style.whiteSpace).toBe("");
flushSync(() => {
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
expect(actions?.getAttribute("data-active")).toBe("true");
expect(pre?.style.overflowX).toBe("hidden");
expect(pre?.style.whiteSpace).toBe("pre-wrap");
expect(pre?.style.overflowWrap).toBe("anywhere");
flushSync(() => {
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
expect(actions?.getAttribute("data-active")).toBeNull();
expect(pre?.style.overflowX).toBe("auto");
expect(pre?.style.whiteSpace).toBe("");
});
});
+151 -12
View File
@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import {
computeMentionMenuPosition,
findClosestAutocompleteAnchor,
@@ -553,6 +553,16 @@ describe("MarkdownEditor", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
});
it("keeps routine slash queries active across spaces", () => {
expect(findMentionMatch("/routine:Weekly release review", "/routine:Weekly release review".length)).toEqual({
trigger: "skill",
marker: "/",
query: "routine:Weekly release review",
atPos: 0,
endPos: "/routine:Weekly release review".length,
});
});
it("does not treat Enter as skill autocomplete accept", () => {
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
@@ -623,6 +633,26 @@ describe("MarkdownEditor", () => {
expect(found).toBe(skillLink);
});
it("finds routine anchors by mention metadata instead of visible text", () => {
const editable = document.createElement("div");
const routineLink = document.createElement("a");
routineLink.setAttribute("href", buildRoutineMentionHref("routine-123"));
routineLink.textContent = "/routine:Weekly release review ";
editable.appendChild(routineLink);
const found = findClosestAutocompleteAnchor(editable, {
id: "routine:routine-123",
kind: "routine",
routineId: "routine-123",
name: "Weekly release review",
status: "active",
href: buildRoutineMentionHref("routine-123"),
aliases: ["routine:Weekly release review", "Weekly release review"],
});
expect(found).toBe(routineLink);
});
it("places the caret after the mention's trailing space when present", () => {
const editable = document.createElement("div");
editable.contentEditable = "true";
@@ -656,7 +686,16 @@ describe("MarkdownEditor", () => {
async function openMentionMenuFor(
handleChange: ReturnType<typeof vi.fn>,
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot> }> {
mentions = [
{
id: "project:project-123",
kind: "project" as const,
name: "Paperclip App",
projectId: "project-123",
projectColor: "#336699",
},
],
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot>; menu: HTMLElement }> {
const root = createRoot(container);
await act(async () => {
@@ -664,15 +703,7 @@ describe("MarkdownEditor", () => {
<MarkdownEditor
value="@Pap"
onChange={handleChange}
mentions={[
{
id: "project:project-123",
kind: "project",
name: "Paperclip App",
projectId: "project-123",
projectColor: "#336699",
},
]}
mentions={mentions}
/>,
);
});
@@ -699,7 +730,9 @@ describe("MarkdownEditor", () => {
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
expect(option).toBeTruthy();
return { option: option!, root };
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]') as HTMLElement | null;
expect(menu).toBeTruthy();
return { option: option!, root, menu: menu! };
}
it("accepts mention selection from a touch tap", async () => {
@@ -723,6 +756,19 @@ describe("MarkdownEditor", () => {
});
});
it("marks the autocomplete portal as floating UI for modal pointer handling", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
const menu = option.closest("[data-paperclip-floating-ui]");
expect(menu).toBeTruthy();
expect(menu?.className).toContain("pointer-events-auto");
await act(async () => {
root.unmount();
});
});
it("does not preventDefault on touchstart so the mention menu can scroll on mobile", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
@@ -740,6 +786,99 @@ describe("MarkdownEditor", () => {
});
});
it("renders all mention matches inside a bounded scroll container", async () => {
const handleChange = vi.fn();
const mentions = Array.from({ length: 12 }, (_, index) => ({
id: `project:project-${index}`,
kind: "project" as const,
name: `Paperclip App ${index}`,
projectId: `project-${index}`,
projectColor: "#336699",
}));
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
expect(options).toHaveLength(12);
expect(menu.className).toContain("max-h-[208px]");
expect(menu.className).toContain("overflow-y-auto");
expect(menu.style.touchAction).toBe("pan-y");
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
act(() => {
menu.dispatchEvent(wheel);
});
expect(wheel.defaultPrevented).toBe(false);
await act(async () => {
root.unmount();
});
});
it("caps rendered mention matches while keeping the menu scrollable", async () => {
const handleChange = vi.fn();
const mentions = Array.from({ length: 60 }, (_, index) => ({
id: `project:project-${index}`,
kind: "project" as const,
name: `Paperclip App ${index}`,
projectId: `project-${index}`,
projectColor: "#336699",
}));
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
expect(options).toHaveLength(50);
expect(menu.className).toContain("overflow-y-auto");
await act(async () => {
root.unmount();
});
});
it("scrolls the active mention option into view during keyboard navigation", async () => {
const handleChange = vi.fn();
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
const mentions = Array.from({ length: 12 }, (_, index) => ({
id: `project:project-${index}`,
kind: "project" as const,
name: `Paperclip App ${index}`,
projectId: `project-${index}`,
projectColor: "#336699",
}));
const { root } = await openMentionMenuFor(handleChange, mentions);
scrollIntoView.mockClear();
const editorScope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement;
expect(editorScope).toBeTruthy();
act(() => {
editorScope?.dispatchEvent(new KeyboardEvent("keydown", {
key: "ArrowDown",
bubbles: true,
cancelable: true,
}));
});
await flush();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
await act(async () => {
root.unmount();
});
if (originalScrollIntoView) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
} else {
delete (HTMLElement.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView;
}
});
it("does not select when the touch moves like a scroll", async () => {
const handleChange = vi.fn();
const { option, root } = await openMentionMenuFor(handleChange);
+64 -13
View File
@@ -31,8 +31,13 @@ import {
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared";
import { Boxes, User } from "lucide-react";
import {
buildAgentMentionHref,
buildProjectMentionHref,
buildRoutineMentionHref,
buildUserMentionHref,
} from "@paperclipai/shared";
import { Boxes, CalendarClock, User } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@@ -41,7 +46,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext";
/* ---- Mention types ---- */
@@ -188,7 +193,7 @@ interface MentionState {
endPos: number;
}
type AutocompleteOption = MentionOption | SkillCommandOption;
type AutocompleteOption = MentionOption | SlashCommandOption;
interface MentionMenuViewport {
offsetLeft: number;
@@ -207,6 +212,7 @@ const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
const MAX_AUTOCOMPLETE_OPTIONS = 50;
/** Roughly one space-width of breathing room between the caret and the menu. */
const MENTION_MENU_CARET_GAP = 10;
@@ -260,7 +266,9 @@ export function findMentionMatch(
if (atPos === -1) return null;
const query = text.slice(atPos + 1, offset);
if (trigger === "skill" && /\s/.test(query)) return null;
if (trigger === "skill" && /\s/.test(query) && !query.toLowerCase().startsWith("routine:")) {
return null;
}
return {
trigger: trigger ?? "mention",
@@ -423,12 +431,21 @@ function mentionMarkdown(option: MentionOption): string {
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
}
function skillMarkdown(option: SkillCommandOption): string {
function slashCommandLabel(option: SlashCommandOption): string {
return option.kind === "routine" ? `/routine:${option.name}` : `/${option.slug}`;
}
function slashCommandMarkdown(option: SlashCommandOption): string {
if (option.kind === "routine") {
return `[${slashCommandLabel(option)}](${buildRoutineMentionHref(option.routineId)}) `;
}
return `[/${option.slug}](${option.href}) `;
}
function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
return option.kind === "skill" || option.kind === "routine"
? slashCommandMarkdown(option)
: mentionMarkdown(option);
}
export function shouldAcceptAutocompleteKey(
@@ -461,6 +478,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
if (option.kind === "skill") {
return parsed.kind === "skill" && parsed.skillId === option.skillId;
}
if (option.kind === "routine") {
return parsed.kind === "routine" && parsed.routineId === option.routineId;
}
if (option.kind === "project" && option.projectId) {
return parsed.kind === "project" && parsed.projectId === option.projectId;
@@ -584,6 +604,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const autocompleteOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const skillEnterArmedRef = useRef(false);
const autocompleteSelectionHandledRef = useRef(false);
const mentionActive = mentionState !== null && (
@@ -629,10 +650,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (!q) return true;
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
})
.slice(0, 8);
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
}
if (!mentions) return [];
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
return mentions
.filter((m) => m.name.toLowerCase().includes(q))
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
}, [mentionState, mentions, slashCommands]);
useImperativeHandle(forwardedRef, () => ({
@@ -785,7 +808,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
continue;
}
if (parsed.kind === "skill") {
if (parsed.kind === "skill" || parsed.kind === "routine") {
applyMentionChipDecoration(link, parsed);
continue;
}
@@ -877,6 +900,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
};
}, [checkMention, mentionActive]);
useEffect(() => {
if (!mentionActive) return;
autocompleteOptionRefs.current.length = filteredMentions.length;
if (mentionIndex >= filteredMentions.length) {
setMentionIndex(Math.max(0, filteredMentions.length - 1));
return;
}
const activeOption = autocompleteOptionRefs.current[mentionIndex];
if (!activeOption || typeof activeOption.scrollIntoView !== "function") return;
activeOption.scrollIntoView({ block: "nearest" });
}, [filteredMentions.length, mentionActive, mentionIndex]);
useEffect(() => {
if (mentionActive) return;
autocompleteSelectionHandledRef.current = false;
@@ -1222,7 +1257,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
{mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
data-paperclip-floating-ui=""
data-testid="mention-autocomplete-menu"
className="pointer-events-auto fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: mentionMenuPosition.top,
left: mentionMenuPosition.left,
@@ -1235,6 +1272,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
key={option.id}
type="button"
tabIndex={-1}
ref={(node) => {
autocompleteOptionRefs.current[i] = node;
}}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
@@ -1256,7 +1296,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
setMentionIndex(i);
}}
>
{option.kind === "skill" ? (
{option.kind === "routine" ? (
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "project" && option.projectId ? (
<span
@@ -1271,7 +1313,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
)}
<span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
<span>
{option.kind === "skill" || option.kind === "routine"
? slashCommandLabel(option)
: option.name}
</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
@@ -1287,6 +1333,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
Skill
</span>
)}
{option.kind === "routine" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Routine
</span>
)}
</button>
))}
</div>,
+121
View File
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MembershipAction } from "./MembershipAction";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("MembershipAction", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(async () => {
const currentRoot = root;
if (currentRoot) {
await act(async () => {
currentRoot.unmount();
});
}
container.remove();
document.body.innerHTML = "";
});
async function renderAction(element: ReactNode) {
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(element);
});
}
function button() {
const element = container.querySelector("button");
expect(element).not.toBeNull();
return element as HTMLButtonElement;
}
it("renders a leave action for joined resources", async () => {
await renderAction(
<MembershipAction
state="joined"
resourceName="Growth"
onJoin={() => {}}
onLeave={() => {}}
/>,
);
expect(button().getAttribute("aria-label")).toBe("Leave Growth");
expect(button().textContent).toContain("Leave");
});
it("renders a join action for left resources", async () => {
await renderAction(
<MembershipAction
state="left"
resourceName="Growth"
onJoin={() => {}}
onLeave={() => {}}
/>,
);
expect(button().getAttribute("aria-label")).toBe("Join Growth");
expect(button().textContent).toContain("Join");
});
it("prevents row navigation when clicked", async () => {
const onLeave = vi.fn();
const parentClick = vi.fn();
await renderAction(
<a href="/projects/growth" onClick={parentClick}>
<MembershipAction
state="joined"
resourceName="Growth"
onJoin={() => {}}
onLeave={onLeave}
/>
</a>,
);
await act(async () => {
button().dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onLeave).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});
it("marks pending actions busy and disabled", async () => {
await renderAction(
<MembershipAction
state="left"
pending
pendingState="joined"
resourceName="Growth"
onJoin={() => {}}
onLeave={() => {}}
/>,
);
expect(button().getAttribute("aria-busy")).toBe("true");
expect(button().disabled).toBe(true);
expect(button().textContent).toContain("Joining...");
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { MouseEvent } from "react";
import { Loader2, LogIn, LogOut } from "lucide-react";
import type { ResourceMembershipState } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
interface MembershipActionProps {
state: ResourceMembershipState;
resourceName: string;
pending?: boolean;
pendingState?: ResourceMembershipState | null;
compact?: boolean;
onJoin: () => void;
onLeave: () => void;
}
export function MembershipAction({
state,
resourceName,
pending = false,
pendingState = null,
compact = false,
onJoin,
onLeave,
}: MembershipActionProps) {
const isLeft = state === "left";
const label = pending
? pendingState === "left" ? "Leaving..." : "Joining..."
: isLeft ? "Join" : "Leave";
const ariaLabel = `${isLeft ? "Join" : "Leave"} ${resourceName}`;
const Icon = pending ? Loader2 : isLeft ? LogIn : LogOut;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (pending) return;
if (isLeft) onJoin();
else onLeave();
}
return (
<span
className={cn(
"flex w-[66px] shrink-0 justify-end",
!isLeft && !compact
? "opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
: "opacity-100",
)}
>
<Button
type="button"
size="xs"
variant="ghost"
aria-label={ariaLabel}
aria-busy={pending ? "true" : undefined}
disabled={pending}
onClick={handleClick}
className="w-[66px]"
>
<Icon className={cn("h-3 w-3", pending && "motion-safe:animate-spin")} />
<span>{label}</span>
</Button>
</span>
);
}
@@ -0,0 +1,23 @@
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
interface MissingPluginTabPlaceholderProps {
defaultTabHref: string;
defaultTabLabel: string;
}
export function MissingPluginTabPlaceholder({
defaultTabHref,
defaultTabLabel,
}: MissingPluginTabPlaceholderProps) {
return (
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
<div className="flex flex-col items-start gap-3">
<p>Workspace plugin tab is not available.</p>
<Button variant="outline" size="sm" asChild>
<Link to={defaultTabHref}>{defaultTabLabel}</Link>
</Button>
</div>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
// @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 { NewAgentDialog } from "./NewAgentDialog";
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
const getInviteOnboardingMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listAdaptersMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const closeNewAgentMock = vi.hoisted(() => vi.fn());
const openNewIssueMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
newAgentOpen: true,
closeNewAgent: closeNewAgentMock,
openNewIssue: openNewIssueMock,
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
vi.mock("../api/access", () => ({
accessApi: {
createCompanyInvite: (companyId: string, input: unknown) =>
createCompanyInviteMock(companyId, input),
getInviteOnboarding: (token: string) => getInviteOnboardingMock(token),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: {
list: (companyId: string) => listAgentsMock(companyId),
},
}));
vi.mock("../api/adapters", () => ({
adaptersApi: {
list: () => listAdaptersMock(),
},
}));
vi.mock("../adapters", () => ({
listUIAdapters: () => [{ type: "claude_local" }, { type: "openclaw_gateway" }],
}));
vi.mock("../adapters/metadata", () => ({
isVisualAdapterChoice: (type: string) => type !== "openclaw_gateway",
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => new Set<string>(),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
// 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("NewAgentDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listAgentsMock.mockResolvedValue([
{ id: "agent-ceo", role: "ceo" },
]);
listAdaptersMock.mockResolvedValue([]);
createCompanyInviteMock.mockResolvedValue({
id: "invite-1",
token: "agent-token",
inviteUrl: "https://paperclip.local/invite/agent-token",
expiresAt: "2026-04-20T00:00:00.000Z",
allowedJoinTypes: "agent",
humanRole: null,
onboardingTextUrl: "https://paperclip.local/api/invites/agent-token/onboarding.txt",
onboardingTextPath: "/api/invites/agent-token/onboarding.txt",
});
getInviteOnboardingMock.mockResolvedValue({
onboarding: {
connectivity: {
connectionCandidates: ["https://paperclip.local"],
testResolutionEndpoint: {
url: "https://paperclip.local/api/invites/agent-token/test-resolution",
},
},
},
});
Object.defineProperty(globalThis.navigator, "clipboard", {
configurable: true,
value: { writeText: clipboardWriteTextMock },
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("generates an external agent onboarding prompt inside the add-agent modal", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<NewAgentDialog />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Add a new agent");
expect(container.textContent).toContain("Invite an external agent");
const inviteButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.startsWith("Invite an external agent"),
);
await act(async () => {
inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Generate a one-time onboarding prompt");
expect(container.textContent).not.toContain("Company Invites");
const generateButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Generate onboarding prompt",
);
await act(async () => {
generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: null,
});
expect(getInviteOnboardingMock).toHaveBeenCalledWith("agent-token");
expect(clipboardWriteTextMock).toHaveBeenCalledWith(
expect.stringContaining("You're invited to join a Paperclip company as an agent."),
);
expect(container.textContent).toContain("Agent onboarding prompt");
expect(container.textContent).toContain("Send this prompt to the external agent");
expect(container.textContent).not.toContain("Optional message for the agent");
expect(container.textContent).not.toContain("Generate onboarding prompt");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Agent invite created",
body: "Agent onboarding prompt ready below and copied to clipboard.",
tone: "success",
});
const backButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Back",
);
await act(async () => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Optional message for the agent");
expect(container.textContent).toContain("Generate onboarding prompt");
await act(async () => {
root.unmount();
});
});
});
+227 -24
View File
@@ -1,8 +1,9 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { adaptersApi } from "../api/adapters";
import { queryKeys } from "@/lib/queryKeys";
@@ -11,15 +12,21 @@ import {
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Bot,
Check,
MailPlus,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { listUIAdapters } from "../adapters";
import { isVisualAdapterChoice } from "../adapters/metadata";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { useToast } from "../context/ToastContext";
/**
* Adapter types that are suitable for agent creation (excludes internal
@@ -27,6 +34,8 @@ import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
*/
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
type NewAgentDialogMode = "choices" | "runtime" | "invite" | "prompt";
function isAgentAdapterType(type: string): boolean {
return !SYSTEM_ADAPTER_TYPES.has(type);
}
@@ -34,10 +43,30 @@ function isAgentAdapterType(type: string): boolean {
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const { pushToast } = useToast();
const navigate = useNavigate();
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
const queryClient = useQueryClient();
const [mode, setMode] = useState<NewAgentDialogMode>("choices");
const [agentMessage, setAgentMessage] = useState("");
const [latestAgentPrompt, setLatestAgentPrompt] = useState<string | null>(null);
const [latestAgentPromptCopied, setLatestAgentPromptCopied] = useState(false);
const disabledTypes = useDisabledAdaptersSync();
function resetDialogState() {
setMode("choices");
setAgentMessage("");
setLatestAgentPrompt(null);
setLatestAgentPromptCopied(false);
}
useEffect(() => {
if (!latestAgentPromptCopied) return;
const timeout = window.setTimeout(() => {
setLatestAgentPromptCopied(false);
}, 1600);
return () => window.clearTimeout(timeout);
}, [latestAgentPromptCopied]);
// Fetch registered adapters from server (syncs disabled store + provides data)
const { data: serverAdapters } = useQuery({
queryKey: queryKeys.adapters.all,
@@ -53,6 +82,7 @@ export function NewAgentDialog() {
});
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", 5);
// Build the adapter grid from the UI registry merged with display metadata.
// This automatically includes external/plugin adapters.
@@ -95,28 +125,110 @@ export function NewAgentDialog() {
}
function handleAdvancedConfig() {
setShowAdvancedCards(true);
setMode("runtime");
}
function handleInviteExternalAgent() {
setMode("invite");
}
function handleAdvancedAdapterPick(adapterType: string) {
closeNewAgent();
setShowAdvancedCards(false);
resetDialogState();
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
}
async function copyText(text: string, unavailableBody: string) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Fall through to the unavailable message below.
}
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
tone: "warn",
});
return false;
}
const createAgentInviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: agentMessage.trim() || null,
}),
onSuccess: async (invite) => {
const base = window.location.origin.replace(/\/+$/, "");
const onboardingTextLink =
invite.onboardingTextUrl ??
invite.onboardingTextPath ??
`/api/invites/${invite.token}/onboarding.txt`;
const onboardingTextUrl = onboardingTextLink.startsWith("http")
? onboardingTextLink
: `${base}${onboardingTextLink}`;
let prompt: string;
try {
const manifest = await accessApi.getInviteOnboarding(invite.token);
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates:
manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl:
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
null,
});
} catch {
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates: null,
testResolutionUrl: null,
});
}
setLatestAgentPrompt(prompt);
setLatestAgentPromptCopied(false);
setMode("prompt");
const copied = await copyText(prompt, "Copy the agent onboarding prompt manually from the field below.");
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
title: "Agent invite created",
body: copied ? "Agent onboarding prompt ready below and copied to clipboard." : "Agent onboarding prompt ready below.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to create agent invite",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
return (
<Dialog
open={newAgentOpen}
onOpenChange={(open) => {
if (!open) {
setShowAdvancedCards(false);
resetDialogState();
closeNewAgent();
}
}}
>
<DialogContent
showCloseButton={false}
className="sm:max-w-md p-0 gap-0 overflow-hidden"
className={cn(
"max-h-[min(calc(100dvh-2rem),46rem)] p-0 gap-0 overflow-hidden flex flex-col",
mode === "invite" || mode === "prompt" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
@@ -126,7 +238,7 @@ export function NewAgentDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => {
setShowAdvancedCards(false);
resetDialogState();
closeNewAgent();
}}
>
@@ -134,8 +246,8 @@ export function NewAgentDialog() {
</Button>
</div>
<div className="p-6 space-y-6">
{!showAdvancedCards ? (
<div className="min-h-0 overflow-y-auto p-6 space-y-6">
{mode === "choices" ? (
<>
{/* Recommendation */}
<div className="text-center space-y-3">
@@ -143,9 +255,8 @@ export function NewAgentDialog() {
<Bot className="h-6 w-6 text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
We recommend letting your CEO handle agent setup they know the
org structure and can configure reporting, permissions, and
adapters.
Ask a leader to propose the hire, configure a runtime yourself,
or send an onboarding prompt to an external agent.
</p>
</div>
@@ -154,28 +265,34 @@ export function NewAgentDialog() {
Ask the CEO to create a new agent
</Button>
{/* Advanced link */}
<div className="text-center">
<button
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
onClick={handleAdvancedConfig}
>
I want advanced configuration myself
</button>
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={handleAdvancedConfig}>
<Settings2 className="h-4 w-4 mr-2" />
Configure a runtime manually
</Button>
<div className="space-y-1">
<Button variant="outline" className="w-full" onClick={handleInviteExternalAgent}>
<MailPlus className="h-4 w-4 mr-2" />
Invite an external agent
</Button>
<p className="text-xs text-muted-foreground text-center">
(OpenClaw, Hermes, or any agent that can call the invite API.)
</p>
</div>
</div>
</>
) : (
) : mode === "runtime" ? (
<>
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvancedCards(false)}
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<p className="text-sm text-muted-foreground">
Choose your adapter type for advanced setup.
Choose the runtime Paperclip should start or resume directly.
</p>
</div>
@@ -207,6 +324,92 @@ export function NewAgentDialog() {
))}
</div>
</>
) : mode === "invite" ? (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite an external agent</h2>
<p className="text-sm text-muted-foreground">
Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key.
</p>
</div>
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">Optional message for the agent</span>
<Textarea
value={agentMessage}
onChange={(event) => setAgentMessage(event.target.value)}
className="min-h-24 resize-y"
placeholder="Add onboarding context, expected role, or first instructions."
maxLength={4000}
/>
</label>
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
Agent invites create a join request first. A company admin still approves the request before the agent can claim its API key.
</div>
<div>
<Button
onClick={() => createAgentInviteMutation.mutate()}
disabled={!selectedCompanyId || createAgentInviteMutation.isPending}
>
{createAgentInviteMutation.isPending ? "Generating…" : "Generate onboarding prompt"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("invite")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Agent onboarding prompt</h2>
{latestAgentPromptCopied ? (
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
) : null}
</div>
<p className="text-sm text-muted-foreground">
Send this prompt to the external agent that should join this company.
</p>
</div>
</div>
<Textarea
readOnly
value={latestAgentPrompt ?? ""}
className="h-[24rem] resize-y font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={!latestAgentPrompt}
onClick={async () => {
if (!latestAgentPrompt) return;
const copied = await copyText(latestAgentPrompt, "Copy the agent onboarding prompt manually from the field above.");
setLatestAgentPromptCopied(copied);
}}
>
{latestAgentPromptCopied ? "Copied prompt" : "Copy prompt"}
</Button>
</div>
)}
</div>
</DialogContent>
+137 -9
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -13,6 +13,13 @@ const dialogState = vi.hoisted(() => ({
closeNewIssue: vi.fn(),
}));
const dialogContentState = vi.hoisted(() => ({
onPointerDownOutside: null as null | ((event: {
detail: { originalEvent: { target: EventTarget | null } };
preventDefault: () => void;
}) => void),
}));
const companyState = vi.hoisted(() => ({
companies: [
{
@@ -45,6 +52,7 @@ const mockIssuesApi = vi.hoisted(() => ({
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
listSummaries: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
@@ -186,13 +194,16 @@ vi.mock("@/components/ui/dialog", () => ({
children,
showCloseButton: _showCloseButton,
onEscapeKeyDown: _onEscapeKeyDown,
onPointerDownOutside: _onPointerDownOutside,
onPointerDownOutside,
...props
}: ComponentProps<"div"> & {
showCloseButton?: boolean;
onEscapeKeyDown?: (event: unknown) => void;
onPointerDownOutside?: (event: unknown) => void;
}) => <div {...props}>{children}</div>,
}) => {
dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside;
return <div {...props}>{children}</div>;
},
}));
vi.mock("@/components/ui/button", () => ({
@@ -216,6 +227,16 @@ vi.mock("@/components/ui/popover", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void | Promise<void>): void | Promise<void> {
let result: unknown;
flushSync(() => {
result = callback();
});
return result && typeof (result as Promise<void>).then === "function"
? (result as Promise<void>).then(() => undefined)
: undefined;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -285,11 +306,14 @@ describe("NewIssueDialog", () => {
dialogState.newIssueOpen = true;
dialogState.newIssueDefaults = {};
dialogState.closeNewIssue.mockReset();
dialogContentState.onPointerDownOutside = null;
toastState.pushToast.mockReset();
mockIssuesApi.create.mockReset();
mockIssuesApi.upsertDocument.mockReset();
mockIssuesApi.uploadAttachment.mockReset();
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
@@ -361,13 +385,15 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
@@ -385,6 +411,15 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
await waitForAssertion(() => {
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", {
projectId: "project-1",
projectWorkspaceId: undefined,
reuseEligible: true,
});
});
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Sub-Issue"));
expect(submitButton).not.toBeUndefined();
@@ -473,7 +508,7 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-100",
@@ -481,6 +516,7 @@ describe("NewIssueDialog", () => {
status: "active",
branchName: "feature/pap-100",
cwd: "/tmp/workspace-1",
projectWorkspaceId: "project-workspace-2",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
@@ -588,6 +624,49 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => {
const title = "验证中文任务";
const description = [
"请用中文回复。",
"日本語: 次の手順を書いてください。",
"हिन्दी: कृपया स्थिति बताएं।",
].join("\n");
const { root } = renderDialog(container);
await flush();
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
await typeTextareaValue(titleInput!, title);
await typeTextareaValue(descriptionInput!, description);
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Issue"));
expect(submitButton).not.toBeUndefined();
await vi.waitFor(() => {
expect(submitButton?.hasAttribute("disabled")).toBe(false);
});
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title,
description,
workMode: "standard",
}),
);
act(() => root.unmount());
});
it("submits planning work mode when planning is selected", async () => {
const { root } = renderDialog(container);
await flush();
@@ -668,10 +747,12 @@ describe("NewIssueDialog", () => {
await flush();
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
typeof element.className === "string" && element.className.includes("max-h-[var(--new-issue-dialog-height)]"),
);
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("h-[var(--new-issue-dialog-height)]");
expect(dialogContent?.className).toContain("overflow-hidden");
expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-top)");
expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-bottom)");
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
@@ -686,6 +767,49 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("keeps priority under the mobile overflow menu", async () => {
const { root } = renderDialog(container);
await flush();
const priorityChip = container.querySelector('[data-testid="new-issue-priority-chip"]');
expect(priorityChip?.className).toContain("hidden");
expect(priorityChip?.className).toContain("sm:inline-flex");
const highPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
expect(highPriorityOption?.textContent).toContain("High");
await act(async () => {
highPriorityOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const selectedHighPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
expect(selectedHighPriorityOption?.className).toContain("bg-accent");
act(() => root.unmount());
});
it("allows editor autocomplete portal pointer events inside the modal", async () => {
const { root } = renderDialog(container);
await flush();
const menu = document.createElement("div");
menu.setAttribute("data-paperclip-floating-ui", "");
const option = document.createElement("button");
menu.appendChild(option);
document.body.appendChild(menu);
const preventDefault = vi.fn();
dialogContentState.onPointerDownOutside?.({
detail: { originalEvent: { target: option } },
preventDefault,
});
expect(preventDefault).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("warns when a sub-issue stops matching the parent workspace", async () => {
mockProjectsApi.list.mockResolvedValue([
{
@@ -700,21 +824,25 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
{
id: "workspace-2",
name: "Other workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-2",
cwd: "/tmp/workspace-2",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
},
]);
+48 -14
View File
@@ -1,4 +1,4 @@
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { IssueWorkMode } from "@paperclipai/shared";
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
@@ -70,6 +70,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
const MOBILE_DIALOG_HEIGHT = "calc(100dvh - max(1rem, env(safe-area-inset-top)) - max(1rem, env(safe-area-inset-bottom)))";
interface IssueDraft {
@@ -468,13 +469,13 @@ export function NewIssueDialog() {
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(effectiveCompanyId!, {
executionWorkspacesApi.listSummaries(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
@@ -1202,10 +1203,11 @@ export function NewIssueDialog() {
<DialogContent
showCloseButton={false}
aria-describedby={undefined}
style={{ "--new-issue-dialog-height": MOBILE_DIALOG_HEIGHT } as CSSProperties}
className={cn(
"flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
"flex h-[var(--new-issue-dialog-height)] max-h-[var(--new-issue-dialog-height)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
expanded
? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]"
? "sm:max-w-2xl sm:h-[var(--new-issue-dialog-height)]"
: "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
@@ -1221,12 +1223,12 @@ export function NewIssueDialog() {
}
// Radix Dialog's modal DismissableLayer calls preventDefault() on
// pointerdown events that originate outside the Dialog DOM tree.
// Popover portals render at the body level (outside the Dialog), so
// touch events on popover content get their default prevented — which
// kills scroll gesture recognition on mobile. Telling Radix "this
// event is handled" skips that preventDefault, restoring touch scroll.
// Popover and editor autocomplete portals render at the body level
// (outside the Dialog), so touch/click events on their content get
// their default prevented. Telling Radix "this event is handled" skips
// that preventDefault, restoring popover scroll and autocomplete taps.
const target = event.detail.originalEvent.target as HTMLElement | null;
if (target?.closest("[data-radix-popper-content-wrapper]")) {
if (target?.closest("[data-radix-popper-content-wrapper], [data-paperclip-floating-ui]")) {
event.preventDefault();
}
}}
@@ -1868,7 +1870,11 @@ export function NewIssueDialog() {
{/* Priority chip */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<button
type="button"
data-testid="new-issue-priority-chip"
className="hidden items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs transition-colors hover:bg-accent/50 sm:inline-flex"
>
{currentPriority ? (
<>
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
@@ -1964,14 +1970,42 @@ export function NewIssueDialog() {
</PopoverContent>
</Popover>
{/* More (dates) */}
{/* More */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
<button
type="button"
data-testid="new-issue-more-menu-trigger"
className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50"
>
<MoreHorizontal className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<PopoverContent className="w-44 p-1" align="start" data-testid="new-issue-more-menu">
<div className="sm:hidden">
<div className="px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Priority
</div>
{priorities.map((p) => (
<button
type="button"
key={p.value}
data-testid={`new-issue-more-priority-${p.value}`}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
p.value === priority && "bg-accent",
)}
onClick={() => {
setPriority(p.value);
setMoreOpen(false);
}}
>
<p.icon className={cn("h-3 w-3", p.color)} />
{p.label}
</button>
))}
<div className="my-1 border-t border-border" />
</div>
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
<Calendar className="h-3 w-3" />
Start date
@@ -5,7 +5,9 @@ import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
CompanySecret,
Routine,
RoutineEnvConfig,
RoutineRevision,
RoutineRevisionSnapshotV1,
} from "@paperclipai/shared";
@@ -95,6 +97,7 @@ function snapshotV1(overrides?: Partial<RoutineRevisionSnapshotV1["routine"]>):
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
env: null,
...overrides,
},
triggers: [],
@@ -321,6 +324,152 @@ describe("RoutineHistoryTab", () => {
expect(successCall).toBeTruthy();
});
it("shows env summary on the revision preview and routes counts into restore dialog", async () => {
const env: RoutineEnvConfig = {
GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" },
LOG_LEVEL: { type: "plain", value: "debug" },
};
const current = createRevision({
id: "revision-2",
revisionNumber: 2,
snapshot: snapshotV1({ env }),
});
const old = createRevision({
id: "revision-1",
revisionNumber: 1,
snapshot: snapshotV1({
env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: 3 } },
}),
});
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
const secrets: CompanySecret[] = [
{
id: "secret-1",
companyId: "company-1",
key: "gh_token",
name: "github-bot",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 4,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
];
await render({ secrets });
expect(container.textContent).toContain("Env");
expect(container.textContent).toContain("2 keys (1 secret ref)");
const oldRow = container.querySelector(
"[data-testid='revision-row-1']",
) as HTMLButtonElement | null;
await act(async () => {
oldRow?.click();
});
await flush();
const restoreButtons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent === "Restore as new revision",
);
expect(restoreButtons.length).toBeGreaterThan(0);
await act(async () => {
restoreButtons[0].click();
});
await flush();
expect(container.textContent).toContain("Routine secrets will revert");
expect(container.textContent).toContain("1 key removed");
expect(container.textContent).toContain("1 key changed");
});
it("labels secret-ref env diffs by changed secret instead of binding kind", async () => {
const current = createRevision({
id: "revision-2",
revisionNumber: 2,
snapshot: snapshotV1({
env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-2", version: "latest" } },
}),
});
const old = createRevision({
id: "revision-1",
revisionNumber: 1,
snapshot: snapshotV1({
env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" } },
}),
});
const secrets: CompanySecret[] = [
{
id: "secret-1",
companyId: "company-1",
key: "old_token",
name: "old-token",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
{
id: "secret-2",
companyId: "company-1",
key: "new_token",
name: "new-token",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
];
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
await render({ secrets });
const oldRow = container.querySelector(
"[data-testid='revision-row-1']",
) as HTMLButtonElement | null;
await act(async () => {
oldRow?.click();
});
await flush();
const compareButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Compare with current",
);
await act(async () => {
compareButton?.click();
});
await flush();
expect(container.textContent).toContain("Env GH_TOKEN secret");
expect(container.textContent).not.toContain("Env GH_TOKEN binding kind");
});
it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => {
const current = createRevision({ id: "revision-2", revisionNumber: 2 });
const old = createRevision({
+200 -2
View File
@@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { History as HistoryIcon, RotateCcw, Search } from "lucide-react";
import type {
CompanySecret,
EnvBinding,
EnvSecretRefBinding,
Routine,
RoutineEnvConfig,
RoutineRevision,
RoutineRevisionSnapshotTriggerV1,
RoutineVariable,
SecretVersionSelector,
} from "@paperclipai/shared";
import {
routinesApi,
@@ -33,6 +38,7 @@ import { MarkdownBody } from "./MarkdownBody";
type AgentLookup = Map<string, { id: string; name: string }>;
type ProjectLookup = Map<string, { id: string; name: string }>;
type SecretLookup = Map<string, CompanySecret>;
type DirtyFieldDescriptor = {
key: string;
@@ -47,6 +53,7 @@ type Props = {
onSaveEdits: () => void;
agents: AgentLookup;
projects: ProjectLookup;
secrets?: CompanySecret[];
onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void;
onRestored?: (response: RestoreRoutineRevisionResponse) => void;
};
@@ -59,9 +66,14 @@ export function RoutineHistoryTab({
onSaveEdits,
agents,
projects,
secrets,
onRestoreSecretMaterials,
onRestored,
}: Props) {
const secretLookup = useMemo<SecretLookup>(
() => new Map((secrets ?? []).map((secret) => [secret.id, secret])),
[secrets],
);
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null);
@@ -277,6 +289,10 @@ export function RoutineHistoryTab({
selectedRevision,
currentRevision,
)}
envDiffCounts={summarizeEnvDiffCounts(
currentRevision.snapshot.routine.env ?? null,
selectedRevision.snapshot.routine.env ?? null,
)}
/>
)}
@@ -289,6 +305,7 @@ export function RoutineHistoryTab({
initialNewRevisionId={currentRevision.id}
agents={agents}
projects={projects}
secrets={secretLookup}
onRestore={(rev) => {
setSelectedRevisionId(rev.id);
setDiffOpen(false);
@@ -498,6 +515,10 @@ function RevisionPreview({
highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border"
}`;
const envSummary = summarizeEnv(snapshot.env ?? null);
const envDiffers = !!currentSnapshot
&& JSON.stringify(normalizeEnv(currentSnapshot.env ?? null))
!== JSON.stringify(normalizeEnv(snapshot.env ?? null));
const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [
{
key: "title",
@@ -541,6 +562,12 @@ function RevisionPreview({
value: snapshot.catchUpPolicy.replaceAll("_", " "),
differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy,
},
{
key: "env",
label: "Env",
value: envSummary,
differs: envDiffers,
},
];
return (
@@ -670,6 +697,7 @@ function RestoreConfirmDialog({
onConfirm,
pending,
recreatedWebhookLabels,
envDiffCounts,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -680,6 +708,7 @@ function RestoreConfirmDialog({
onConfirm: () => void;
pending: boolean;
recreatedWebhookLabels: string[];
envDiffCounts: EnvDiffCounts;
}) {
const newRevisionNumber = currentRevisionNumber + 1;
return (
@@ -698,6 +727,12 @@ function RestoreConfirmDialog({
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
Routine field values, variables, and schedule cron will revert.
</li>
{envDiffCounts.total > 0 && (
<li className="flex items-start gap-2">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
Routine secrets will revert: {formatEnvDiffCounts(envDiffCounts)}.
</li>
)}
<li className="flex items-start gap-2">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
Previous run history is preserved.
@@ -743,6 +778,7 @@ function RoutineRevisionDiffModal({
initialNewRevisionId,
agents,
projects,
secrets,
onRestore,
}: {
open: boolean;
@@ -752,6 +788,7 @@ function RoutineRevisionDiffModal({
initialNewRevisionId: string;
agents: AgentLookup;
projects: ProjectLookup;
secrets: SecretLookup;
onRestore: (revision: RoutineRevision) => void;
}) {
const [leftId, setLeftId] = useState<string>(initialOldRevisionId);
@@ -767,8 +804,8 @@ function RoutineRevisionDiffModal({
const left = revisions.find((r) => r.id === leftId) ?? null;
const right = revisions.find((r) => r.id === rightId) ?? null;
const fieldChanges = useMemo(
() => (left && right ? computeFieldChanges(left, right, agents, projects) : []),
[left, right, agents, projects],
() => (left && right ? computeFieldChanges(left, right, agents, projects, secrets) : []),
[left, right, agents, projects, secrets],
);
const descriptionDiff = useMemo<DiffRow[]>(
() => (left && right
@@ -1003,6 +1040,7 @@ function computeFieldChanges(
right: RoutineRevision,
agents: AgentLookup,
projects: ProjectLookup,
secrets: SecretLookup,
): Array<{ field: string; oldValue: string | null; newValue: string | null }> {
const oldRoutine = left.snapshot.routine;
const newRoutine = right.snapshot.routine;
@@ -1042,10 +1080,170 @@ function computeFieldChanges(
newValue: summarizeVariables(newRoutine.variables),
});
}
compareEnv(oldRoutine.env ?? null, newRoutine.env ?? null, secrets, changes);
compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes);
return changes;
}
function normalizeEnv(env: RoutineEnvConfig | null): Record<string, EnvBinding> {
if (!env) return {};
return env;
}
function envBindingKind(binding: EnvBinding): "plain" | "secret_ref" {
if (typeof binding === "string") return "plain";
if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") {
return "secret_ref";
}
return "plain";
}
function asSecretRef(binding: EnvBinding): EnvSecretRefBinding | null {
if (typeof binding === "string") return null;
if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") {
return binding;
}
return null;
}
function formatVersionSelector(version: SecretVersionSelector | undefined): string {
if (version == null || version === "latest") return "latest";
return `v${version}`;
}
function describeSecretRef(ref: EnvSecretRefBinding, secrets: SecretLookup): string {
const secret = secrets.get(ref.secretId);
const name = secret?.name ?? "<missing-secret>";
return `${name} ${formatVersionSelector(ref.version)}`;
}
function describeEnvBinding(binding: EnvBinding | undefined, secrets: SecretLookup): string {
if (binding === undefined) return "—";
const ref = asSecretRef(binding);
if (ref) return `secret_ref → ${describeSecretRef(ref, secrets)}`;
return "plain (set)";
}
function summarizeEnv(env: RoutineEnvConfig | null): string {
const entries = Object.entries(normalizeEnv(env));
if (entries.length === 0) return "";
const secretCount = entries.filter(([, binding]) => envBindingKind(binding) === "secret_ref").length;
const keyLabel = entries.length === 1 ? "key" : "keys";
if (secretCount === 0) return `${entries.length} ${keyLabel}`;
return `${entries.length} ${keyLabel} (${secretCount} secret ${secretCount === 1 ? "ref" : "refs"})`;
}
type EnvDiffCounts = {
added: number;
removed: number;
changed: number;
total: number;
};
function summarizeEnvDiffCounts(
current: RoutineEnvConfig | null,
target: RoutineEnvConfig | null,
): EnvDiffCounts {
const currentRec = normalizeEnv(current);
const targetRec = normalizeEnv(target);
let added = 0;
let removed = 0;
let changed = 0;
const keys = new Set<string>([...Object.keys(currentRec), ...Object.keys(targetRec)]);
for (const key of keys) {
const inCurrent = key in currentRec;
const inTarget = key in targetRec;
if (inTarget && !inCurrent) {
added += 1;
continue;
}
if (!inTarget && inCurrent) {
removed += 1;
continue;
}
if (JSON.stringify(currentRec[key]) !== JSON.stringify(targetRec[key])) {
changed += 1;
}
}
return { added, removed, changed, total: added + removed + changed };
}
function formatEnvDiffCounts(counts: EnvDiffCounts): string {
const parts: string[] = [];
if (counts.added > 0) parts.push(`${counts.added} ${counts.added === 1 ? "key" : "keys"} added`);
if (counts.removed > 0) parts.push(`${counts.removed} ${counts.removed === 1 ? "key" : "keys"} removed`);
if (counts.changed > 0) parts.push(`${counts.changed} ${counts.changed === 1 ? "key" : "keys"} changed`);
return parts.join(", ");
}
function compareEnv(
oldEnv: RoutineEnvConfig | null,
newEnv: RoutineEnvConfig | null,
secrets: SecretLookup,
changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>,
) {
const oldRec = normalizeEnv(oldEnv);
const newRec = normalizeEnv(newEnv);
const keys = new Set<string>([...Object.keys(oldRec), ...Object.keys(newRec)]);
const sortedKeys = [...keys].sort();
for (const key of sortedKeys) {
const oldBinding = oldRec[key];
const newBinding = newRec[key];
const inOld = key in oldRec;
const inNew = key in newRec;
if (inNew && !inOld) {
changes.push({
field: `Env added (${key})`,
oldValue: "—",
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
if (!inNew && inOld) {
changes.push({
field: `Env removed (${key})`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: "—",
});
continue;
}
if (JSON.stringify(oldBinding) === JSON.stringify(newBinding)) continue;
const oldKind = envBindingKind(oldBinding);
const newKind = envBindingKind(newBinding);
if (oldKind !== newKind) {
changes.push({
field: `Env ${key} binding kind`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
if (newKind === "secret_ref") {
const oldRef = asSecretRef(oldBinding)!;
const newRef = asSecretRef(newBinding)!;
if (oldRef.secretId !== newRef.secretId) {
changes.push({
field: `Env ${key} secret`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
changes.push({
field: `Env ${key} version`,
oldValue: describeSecretRef(oldRef, secrets),
newValue: describeSecretRef(newRef, secrets),
});
continue;
}
changes.push({
field: `Env ${key} value`,
oldValue: "plain (set)",
newValue: "plain (changed)",
});
}
}
function summarizeVariables(variables: RoutineVariable[]): string {
if (variables.length === 0) return "(none)";
return variables
+1 -1
View File
@@ -134,7 +134,7 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
{runNowButton ? (
<Button
variant="ghost"
variant="outline"
size="sm"
disabled={runDisabled}
onClick={() => onRunNow(routine)}
+49 -2
View File
@@ -67,7 +67,15 @@ vi.mock("../hooks/useInboxBadge", () => ({
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null,
PluginSlotOutlet: ({ slotTypes }: { slotTypes: string[] }) => (
<div data-plugin-slot-types={slotTypes.join(",")}>Plugin slot outlet</div>
),
}));
vi.mock("@/plugins/launchers", () => ({
PluginLauncherOutlet: ({ placementZones }: { placementZones: string[] }) => (
<div data-plugin-launcher-zone={placementZones.join(",")}>Plugin launcher outlet</div>
),
}));
vi.mock("./SidebarCompanyMenu", () => ({
@@ -129,7 +137,7 @@ describe("Sidebar", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const topSearchLink = container.querySelector('a[aria-label="Search"]');
const topSearchLink = container.querySelector('a[aria-label="Open search"]');
expect(topSearchLink?.getAttribute("href")).toBe("/search");
const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
expect(workLinks).not.toContain("Search");
@@ -139,6 +147,45 @@ describe("Sidebar", () => {
});
});
it("renders plugin sidebar launchers inside the Work section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
.find((node) => node.getAttribute("data-plugin-launcher-zone") === "sidebar");
expect(workSection?.textContent).toContain("Plugin launcher outlet");
const workSectionContainer = workSection?.parentElement?.parentElement;
expect(workSectionContainer?.textContent).toContain("Work");
expect(workSectionContainer?.textContent).toContain("Issues");
expect(workSectionContainer?.textContent).toContain("Goals");
await act(async () => {
root.unmount();
});
});
it("renders plugin sidebar slots in Work below Workspaces", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
const sidebarSlot = [...container.querySelectorAll("nav [data-plugin-slot-types]")]
.find((node) => node.getAttribute("data-plugin-slot-types") === "sidebar");
expect(sidebarSlot?.textContent).toContain("Plugin slot outlet");
const workSectionContainer = sidebarSlot?.parentElement?.parentElement;
const workText = workSectionContainer?.textContent ?? "";
expect(workText).toContain("Work");
expect(workText).toContain("Workspaces");
expect(workText.indexOf("Workspaces")).toBeLessThan(workText.indexOf("Plugin slot outlet"));
const primaryNavText = container.querySelector("nav > div:first-child")?.textContent ?? "";
expect(primaryNavText).toContain("Inbox");
expect(primaryNavText).not.toContain("Plugin slot outlet");
await act(async () => {
root.unmount();
});
});
it("does not flash the Workspaces link while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
+19 -11
View File
@@ -27,6 +27,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() {
@@ -61,8 +62,8 @@ export function Sidebar() {
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label="Search"
title="Search"
aria-label="Open search"
title="Open search"
>
<NavLink to="/search">
<Search className="h-4 w-4" />
@@ -70,12 +71,13 @@ export function Sidebar() {
</Button>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
{/* New Issue button aligned with nav items */}
<button
onClick={() => openNewIssue()}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
data-slot="icon-button"
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className="truncate">New Issue</span>
@@ -89,13 +91,6 @@ export function Sidebar() {
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
missingBehavior="placeholder"
/>
</div>
<SidebarSection label="Work">
@@ -105,6 +100,19 @@ export function Sidebar() {
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
/>
</SidebarSection>
<SidebarProjects />
@@ -110,7 +110,7 @@ describe("SidebarAccountMenu", () => {
expect(document.body.textContent).toContain("Paperclip v1.2.3");
expect(document.body.textContent).toContain("jane@example.com");
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
.toContain("w-[var(--radix-popover-trigger-width)]");
.toContain("w-[277px]");
await act(async () => {
root.unmount();
+1 -1
View File
@@ -160,7 +160,7 @@ export function SidebarAccountMenu({
side="top"
align="start"
sideOffset={10}
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
className="w-[277px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
>
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
<div className="-mt-8 px-4 pb-4">
+87 -2
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarAgents } from "./SidebarAgents";
@@ -22,6 +22,11 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateAgent: vi.fn(),
}));
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
@@ -91,6 +96,10 @@ vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -99,6 +108,14 @@ if (!globalThis.PointerEvent) {
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-1",
@@ -177,6 +194,7 @@ describe("SidebarAgents", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let memberships: ResourceMemberships;
beforeEach(() => {
container = document.createElement("div");
@@ -193,6 +211,27 @@ describe("SidebarAgents", () => {
user: { id: "user-1" },
});
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
memberships = {
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateAgent.mockImplementation((_companyId, agentId, data) => {
memberships = {
...memberships,
agentMemberships: {
...memberships.agentMemberships,
[agentId]: data.state,
},
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "agent",
resourceId: agentId,
state: data.state,
});
});
localStorage.clear();
});
@@ -311,6 +350,31 @@ describe("SidebarAgents", () => {
expect(agentLinkLabels(container)).toEqual(["Bravo", "Charlie", "Alpha"]);
});
it("filters left agents only after membership state loads", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }),
]);
let resolveMemberships!: (value: unknown) => void;
mockResourceMembershipsApi.listMine.mockReturnValue(new Promise((resolve) => {
resolveMemberships = resolve;
}));
await renderSidebarAgents();
expect(agentLinkLabels(container)).toEqual(["Alpha", "Beta"]);
await act(async () => {
resolveMemberships({
projectMemberships: {},
agentMemberships: { "agent-1": "left" },
updatedAt: null,
});
});
await flushReact();
expect(agentLinkLabels(container)).toEqual(["Beta"]);
});
it("shows edit and pause actions for an active sidebar agent", async () => {
await renderSidebarAgents();
await openAgentMenu();
@@ -333,6 +397,27 @@ describe("SidebarAgents", () => {
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent paused" }));
});
it("offers leave agent from each sidebar agent menu", async () => {
await renderSidebarAgents();
await openAgentMenu();
const leaveItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Leave agent"));
expect(leaveItem).toBeTruthy();
await act(async () => {
leaveItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateAgent).toHaveBeenCalledWith(
"company-1",
"agent-1",
{ state: "left" },
);
expect(agentLinkLabels(container)).toEqual([]);
});
it("shows resume for paused sidebar agents", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ status: "paused", pauseReason: "manual", pausedAt: new Date("2026-01-02T00:00:00Z") }),
+47 -3
View File
@@ -3,6 +3,8 @@ import { Link, NavLink, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
MoreHorizontal,
Loader2,
LogOut,
PauseCircle,
Pencil,
PlayCircle,
@@ -20,6 +22,7 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import {
AGENT_SORT_MODE_UPDATED_EVENT,
getAgentSortModeStorageKey,
@@ -82,6 +85,8 @@ function SidebarAgentItem({
agent,
disabled,
isMobile,
leaving,
onLeaveAgent,
onPauseResume,
runCount,
setSidebarOpen,
@@ -91,6 +96,8 @@ function SidebarAgentItem({
agent: Agent;
disabled: boolean;
isMobile: boolean;
leaving: boolean;
onLeaveAgent: (agent: Agent) => void;
onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
runCount: number;
setSidebarOpen: (open: boolean) => void;
@@ -118,7 +125,7 @@ function SidebarAgentItem({
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 text-[13px] font-medium transition-colors",
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
@@ -186,6 +193,17 @@ function SidebarAgentItem({
{isPaused ? <PlayCircle className="size-4" /> : <PauseCircle className="size-4" />}
<span>{pauseResumeDisabledLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (leaving) return;
onLeaveAgent(agent);
}}
disabled={leaving}
>
{leaving ? <Loader2 className="size-4 motion-safe:animate-spin" /> : <LogOut className="size-4" />}
<span>{leaving ? "Leaving..." : "Leave agent"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -211,6 +229,8 @@ export function SidebarAgents() {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
@@ -229,10 +249,15 @@ export function SidebarAgents() {
const visibleAgents = useMemo(() => {
const filtered = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
(a: Agent) =>
a.status !== "terminated" &&
(
!membershipsQuery.isSuccess ||
resourceMembershipState(membershipsQuery.data, "agent", a.id) !== "left"
)
);
return filtered;
}, [agents]);
}, [agents, membershipsQuery.data, membershipsQuery.isSuccess]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const sortModeStorageKey = useMemo(() => {
if (!selectedCompanyId) return null;
@@ -343,6 +368,23 @@ export function SidebarAgents() {
},
});
const leaveAgent = useCallback(
(agent: Agent) => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
}),
[membershipMutation],
);
const agentLeaving = useCallback(
(agent: Agent) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id,
[membershipMutation.isPending, membershipMutation.variables],
);
return (
<SidebarSection
label="Agents"
@@ -374,6 +416,8 @@ export function SidebarAgents() {
agent={agent}
disabled={pendingAgentIds.has(agent.id)}
isMobile={isMobile}
leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
runCount={runCount}
setSidebarOpen={setSidebarOpen}
+1 -1
View File
@@ -41,7 +41,7 @@ export function SidebarNavItem({
onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
"flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
+140 -3
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import type { Project, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarProjects } from "./SidebarProjects";
@@ -16,9 +16,17 @@ const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateProject: vi.fn(),
}));
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockPersistOrder = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
const mockPointerState = vi.hoisted(() => ({ fine: true }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
@@ -63,11 +71,17 @@ vi.mock("../context/DialogContext", () => ({
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
isMobile: mockSidebarState.isMobile,
setSidebarOpen: mockSetSidebarOpen,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({
pushToast: mockPushToast,
}),
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
@@ -76,6 +90,10 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: Project[] }) => {
const curatedOrder = ["project-b", "project-a", "project-c"];
@@ -105,6 +123,14 @@ if (!globalThis.PointerEvent) {
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeProject(overrides: Partial<Project>): Project {
return {
id: "project-a",
@@ -168,6 +194,17 @@ async function openProjectsMenu(container: HTMLElement) {
await flushReact();
}
async function openProjectMenu(label = "Open actions for Alpha") {
const trigger = document.body.querySelector(`button[aria-label="${label}"]`);
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function chooseSortMode(label: string) {
const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
.find((element) => element.textContent?.includes(label));
@@ -183,6 +220,7 @@ describe("SidebarProjects", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let memberships: ResourceMemberships;
beforeEach(() => {
container = document.createElement("div");
@@ -192,6 +230,23 @@ describe("SidebarProjects", () => {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
localStorage.clear();
mockSidebarState.isMobile = false;
mockPointerState.fine = true;
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("(hover: hover)") && query.includes("(pointer: fine)")
? mockPointerState.fine
: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockProjectsApi.list.mockResolvedValue([
makeProject({
id: "project-a",
@@ -219,6 +274,27 @@ describe("SidebarProjects", () => {
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1" },
});
memberships = {
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
mockResourceMembershipsApi.updateProject.mockImplementation((_companyId, projectId, data) => {
memberships = {
...memberships,
projectMemberships: {
...memberships.projectMemberships,
[projectId]: data.state,
},
updatedAt: new Date(),
};
return Promise.resolve({
resourceType: "project",
resourceId: projectId,
state: data.state,
});
});
});
afterEach(async () => {
@@ -254,6 +330,25 @@ describe("SidebarProjects", () => {
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeTruthy();
});
it("uses plain project rows for top mode on mobile", async () => {
mockSidebarState.isMobile = true;
await renderSidebarProjects();
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
});
it("uses plain project rows for top mode on coarse pointer screens", async () => {
mockPointerState.fine = false;
await renderSidebarProjects();
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
});
it("uses the heading for section menu and the plus button for project creation", async () => {
@@ -296,4 +391,46 @@ describe("SidebarProjects", () => {
expect(projectLinkLabels(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
});
it("filters left projects only after membership state loads", async () => {
let resolveMemberships!: (value: unknown) => void;
mockResourceMembershipsApi.listMine.mockReturnValue(new Promise((resolve) => {
resolveMemberships = resolve;
}));
await renderSidebarProjects();
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
await act(async () => {
resolveMemberships({
projectMemberships: { "project-a": "left" },
agentMemberships: {},
updatedAt: null,
});
});
await flushReact();
expect(projectLinkLabels(container)).toEqual(["Bravo", "Charlie"]);
});
it("offers leave project from each sidebar project menu", async () => {
await renderSidebarProjects();
await openProjectMenu();
const leaveItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Leave project"));
expect(leaveItem).toBeTruthy();
await act(async () => {
leaveItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockResourceMembershipsApi.updateProject).toHaveBeenCalledWith(
"company-1",
"project-a",
{ state: "left" },
);
expect(projectLinkLabels(container)).toEqual(["Bravo", "Charlie"]);
});
});
+122 -28
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Plus } from "lucide-react";
import { FolderOpen, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react";
import {
DndContext,
MouseSensor,
@@ -21,8 +21,16 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import {
getProjectSortModeStorageKey,
@@ -41,6 +49,7 @@ const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "alphabetical", label: "Alphabetical" },
{ value: "recent", label: "Recent" },
];
const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
type ProjectItemProps = {
activeProjectRef: string | null;
@@ -50,6 +59,8 @@ type ProjectItemProps = {
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
setSidebarOpen: (open: boolean) => void;
onLeaveProject: (project: Project) => void;
leaving?: boolean;
isDragging?: boolean;
};
@@ -74,6 +85,26 @@ function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Pr
return sorted;
}
function hasFineReorderPointer() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia(REORDER_POINTER_MEDIA).matches;
}
function useFineReorderPointer() {
const [matches, setMatches] = useState(hasFineReorderPointer);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia(REORDER_POINTER_MEDIA);
const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return matches;
}
function ProjectItem({
activeProjectRef,
companyId,
@@ -82,36 +113,70 @@ function ProjectItem({
project,
projectSidebarSlots,
setSidebarOpen,
onLeaveProject,
leaving = false,
isDragging = false,
}: ProjectItemProps) {
const routeRef = projectRouteRef(project);
return (
<div className="flex flex-col gap-0.5">
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
<div className="group/project relative flex items-center">
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 transition-opacity data-[state=open]:pointer-events-auto data-[state=open]:opacity-100",
isMobile
? "opacity-100"
: "pointer-events-none opacity-0 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100",
)}
aria-label={`Open actions for ${project.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={() => {
if (leaving) return;
onLeaveProject(project);
}}
disabled={leaving}
>
{leaving ? <Loader2 className="size-4 motion-safe:animate-spin" /> : <LogOut className="size-4" />}
<span>{leaving ? "Leaving..." : "Leave project"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => (
@@ -167,6 +232,7 @@ export function SidebarProjects() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const fineReorderPointer = useFineReorderPointer();
const location = useLocation();
const { data: projects } = useQuery({
@@ -174,6 +240,8 @@ export function SidebarProjects() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -196,8 +264,12 @@ export function SidebarProjects() {
});
const visibleProjects = useMemo(
() => (projects ?? []).filter((project: Project) => !project.archivedAt),
[projects],
() => (projects ?? []).filter((project: Project) => {
if (project.archivedAt) return false;
if (!membershipsQuery.isSuccess) return true;
return resourceMembershipState(membershipsQuery.data, "project", project.id) !== "left";
}),
[membershipsQuery.data, membershipsQuery.isSuccess, projects],
);
const { orderedProjects, persistOrder } = useProjectOrder({
projects: visibleProjects,
@@ -209,6 +281,7 @@ export function SidebarProjects() {
[orderedProjects, sortMode],
);
const isTopMode = sortMode === "top";
const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
@@ -276,6 +349,23 @@ export function SidebarProjects() {
[isTopMode, orderedProjects, persistOrder],
);
const leaveProject = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
}),
[membershipMutation],
);
const projectLeaving = useCallback(
(project: Project) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id,
[membershipMutation.isPending, membershipMutation.variables],
);
const renderProject = (project: Project) => (
<ProjectItem
key={project.id}
@@ -286,6 +376,8 @@ export function SidebarProjects() {
project={project}
projectSidebarSlots={projectSidebarSlots}
setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject}
leaving={projectLeaving(project)}
/>
);
@@ -310,7 +402,7 @@ export function SidebarProjects() {
onRadioValueChange: persistSortMode,
}}
>
{isTopMode ? (
{canReorderProjects ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -331,6 +423,8 @@ export function SidebarProjects() {
project={project}
projectSidebarSlots={projectSidebarSlots}
setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject}
leaving={projectLeaving(project)}
/>
))}
</div>
+3 -1
View File
@@ -95,6 +95,7 @@ function SidebarSectionHeader({
<DropdownMenuTrigger asChild>
<button
type="button"
data-slot="icon-button"
className={cn(
"inline-flex min-w-0 max-w-full items-center rounded-md px-1 py-0.5 text-left outline-none transition-colors",
"hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
@@ -150,12 +151,13 @@ function SidebarSectionHeader({
);
return (
<div className="group/sidebar-section px-3 py-1.5">
<div className="group/sidebar-section px-3 py-1.5 pointer-coarse:py-1">
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
{collapsible ? (
<CollapsibleTrigger asChild>
<button
type="button"
data-slot="icon-button"
className="absolute -left-4 flex h-5 w-5 items-center justify-center rounded-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
>
@@ -0,0 +1,33 @@
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SourceResolvedFoldBadgeProps {
className?: string;
title?: string;
/** When true (default) the leading sparkles icon is rendered. */
showIcon?: boolean;
}
export function SourceResolvedFoldBadge({
className,
title = "System folded this run as a source-resolved false positive.",
showIcon = true,
}: SourceResolvedFoldBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
"border-emerald-300/60 bg-emerald-50/80 text-emerald-900",
"dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200",
className,
)}
title={title}
aria-label="Source-resolved watchdog fold"
>
{showIcon ? <Sparkles className="h-3 w-3 text-emerald-700 dark:text-emerald-300" aria-hidden /> : null}
Source-resolved
</span>
);
}
export default SourceResolvedFoldBadge;
@@ -0,0 +1,177 @@
import { Sparkles } from "lucide-react";
import { Link } from "@/lib/router";
import { cn, relativeTime } from "@/lib/utils";
import {
type SourceResolvedWatchdogFold,
formatCleanupOutcome,
formatSilenceAgeMs,
shortenEvidenceId,
} from "@/lib/source-resolved-watchdog-fold";
export interface SourceResolvedFoldCalloutProps {
fold: SourceResolvedWatchdogFold;
/** Time the run was finalized — used for the "system audit · {when}" header chip. */
finalizedAt?: string | Date | null;
className?: string;
}
function isoOrLocaleString(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function issueLink(id: string, identifier: string | null) {
return `/issues/${identifier ?? id}`;
}
function MetaRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-x-3 gap-y-0 py-1 text-xs sm:grid-cols-[12rem_1fr]">
<dt className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-emerald-900/70 dark:text-emerald-200/70">
{label}
</dt>
<dd className="min-w-0 break-words text-emerald-950 dark:text-emerald-100">{children}</dd>
</div>
);
}
export function SourceResolvedFoldCallout({
fold,
finalizedAt,
className,
}: SourceResolvedFoldCalloutProps) {
const sourceLabel = fold.sourceIssueIdentifier ?? fold.sourceIssueId.slice(0, 8);
const evidenceShort = shortenEvidenceId(fold.sameRunEvidenceId);
const evidenceAt = isoOrLocaleString(fold.sameRunEvidenceAt);
const silenceAgeLabel = formatSilenceAgeMs(fold.silenceAgeMs);
const silenceStartedLabel = isoOrLocaleString(fold.silenceStartedAt);
const cleanupLabel = formatCleanupOutcome(fold.cleanup.outcome);
const finalizedRelative = finalizedAt ? relativeTime(finalizedAt) : null;
const evaluationLabel = fold.evaluationIssueIdentifier ?? fold.evaluationIssueId?.slice(0, 8);
return (
<section
role="status"
aria-label="Source-resolved watchdog fold"
data-source-resolved-fold
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950",
"dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
"bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
)}
aria-hidden
>
<Sparkles className="h-4 w-4 text-emerald-700 dark:text-emerald-300" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className="text-emerald-900 dark:text-emerald-200">SOURCE-RESOLVED FOLD</span>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
system audit
</span>
{finalizedRelative ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{finalizedRelative}
</span>
</>
) : null}
</div>
<p className="mt-1 text-[14px] leading-6">
This run was folded as a source-resolved false positive.
</p>
</div>
</header>
<dl
className={cn(
"divide-y border-t bg-background/40 px-3 py-2 sm:px-4 dark:bg-background/20",
"border-emerald-300/60 dark:border-emerald-500/30",
"[&>*]:border-emerald-300/40 dark:[&>*]:border-emerald-500/20",
)}
>
<MetaRow label="Source issue">
<span className="inline-flex flex-wrap items-center gap-1.5">
<Link
to={issueLink(fold.sourceIssueId, fold.sourceIssueIdentifier)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{sourceLabel}
</Link>
<span className="rounded-md border border-emerald-300/60 bg-background/60 px-1.5 py-0.5 text-[11px] font-medium text-emerald-900 dark:border-emerald-500/30 dark:text-emerald-200">
{fold.sourceIssueStatus}
</span>
</span>
</MetaRow>
<MetaRow label="Same-run evidence">
<span className="inline-flex flex-wrap items-baseline gap-1.5">
<span className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] text-emerald-900 dark:bg-background/40 dark:text-emerald-100">
{fold.sameRunEvidenceKind}
</span>
<code
className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] text-emerald-900 dark:bg-background/40 dark:text-emerald-100"
title={fold.sameRunEvidenceId}
>
{evidenceShort}
</code>
{evidenceAt ? (
<span className="text-[11px] text-muted-foreground">at {evidenceAt}</span>
) : null}
</span>
</MetaRow>
<MetaRow label="Silence age before fold">
{silenceAgeLabel ? (
<span>
{silenceAgeLabel}
{silenceStartedLabel ? (
<span className="text-muted-foreground"> (silence started {silenceStartedLabel})</span>
) : null}
</span>
) : (
<span className="text-muted-foreground">unknown</span>
)}
</MetaRow>
<MetaRow label="Process cleanup">
<span
className="inline-flex flex-wrap items-baseline gap-1.5"
title={fold.cleanup.outcome}
>
<span>{cleanupLabel}</span>
{fold.cleanup.error ? (
<span className="text-muted-foreground"> {fold.cleanup.error}</span>
) : null}
</span>
</MetaRow>
{fold.evaluationIssueId ? (
<MetaRow label="Evaluation issue">
<Link
to={issueLink(fold.evaluationIssueId, fold.evaluationIssueIdentifier)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{evaluationLabel}
</Link>
</MetaRow>
) : null}
</dl>
</section>
);
}
export default SourceResolvedFoldCallout;
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanySettingsNav, getCompanySettingsTab } from "./CompanySettingsNav";
@@ -40,6 +40,14 @@ vi.mock("@/components/PageTabBar", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("CompanySettingsNav", () => {
let container: HTMLDivElement;
@@ -60,29 +68,34 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/company/settings/cloud-upstream")).toBe("cloud-upstream");
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
});
it("renders the active tab and navigates when a different tab is selected", async () => {
currentPathname = "/PAP/company/settings/access";
currentPathname = "/PAP/company/settings/members";
const root = createRoot(container);
await act(async () => {
root.render(<CompanySettingsNav />);
});
expect(container.textContent).toContain("access");
expect(container.textContent).toContain("members");
expect(pageTabBarMock).toHaveBeenCalledWith(
expect.objectContaining({
value: "access",
value: "members",
items: [
{ value: "general", label: "General" },
{ value: "environments", label: "Environments" },
{ value: "secrets", label: "Secrets" },
{ value: "access", label: "Access" },
{ value: "cloud-upstream", label: "Cloud upstream" },
{ value: "members", label: "Members" },
{ value: "invites", label: "Invites" },
{ value: "secrets", label: "Secrets" },
],
}),
);
@@ -5,9 +5,10 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [
{ value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
{ value: "access", label: "Access", href: "/company/settings/access" },
{ value: "cloud-upstream", label: "Cloud upstream", href: "/company/settings/cloud-upstream" },
{ value: "members", label: "Members", href: "/company/settings/members" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
] as const;
type CompanySettingsTab = (typeof items)[number]["value"];
@@ -17,8 +18,12 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "environments";
}
if (pathname.includes("/company/settings/access")) {
return "access";
if (pathname.includes("/company/settings/cloud-upstream")) {
return "cloud-upstream";
}
if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
return "members";
}
if (pathname.includes("/company/settings/invites")) {
@@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onmessage = (message) => {
+1 -1
View File
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[max(1rem,env(safe-area-inset-top))] md:top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 md:translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[max(1rem,env(safe-area-inset-top))] md:top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 md:translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg [&>*]:min-w-0",
className
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 min-w-0 w-full max-w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}