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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") ? (
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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...");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user