Files
PaperClipAI/ui/src/App.test.tsx
T
DottaandPaperclip 145d86911b Remove decision training UI (#11225)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Decisions desk shows work that needs an operator response.
> - It also exposed decision-training actions and a separate training
library.
> - Paperclip does not plan to use these training surfaces now.
> - Keeping inactive controls makes the Decisions workflow harder to
scan.
> - This pull request removes the training UI and keeps the backend
snapshot contract unchanged.
> - The benefit is a smaller and clearer Decisions workflow without a
data migration.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The Decisions desk currently exposes training controls, training state,
and a separate training library route.

**Subsystem affected**

`ui/` — React and Vite board UI.

**Current behavior**

Operators can open a training library from the Decisions toolbar. They
can also mark a decision for training from rows and inspect the result
in a drawer.

**Proposed behavior**

Remove the training controls, badges, drawer, library pages, and routes
from the Decisions UI. Keep the server APIs and stored training examples
unchanged.

**Reason and benefit**

The product does not plan to use decision training now. Removing the
unused surfaces reduces Decisions UI noise and avoids presenting a
workflow that operators should not use.

**Breaking changes**

The `/decisions/training` UI routes are no longer registered. Existing
server endpoints and stored decision-training data remain compatible.

## What Changed

- Removed decision-training controls and state from Decisions toolbars,
rows, queue pages, and shelves.
- Removed the training drawer, library, inspector, API client, helpers,
query keys, and routes.
- Added route and row regressions that assert training UI does not
return.
- Updated the Decisions Storybook description to match the available
controls.

## Verification

- `pnpm exec vitest run ui/src/App.test.tsx
ui/src/components/AttentionQueueRow.test.tsx` — 32 tests passed.
- `pnpm check:token-gates` — all gates clean.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — server and UI partitions passed. The CLI partition
had one environment-only failure because this agent runtime injects
static AWS credentials. The exact CLI file passed all 8 tests when those
credential variables were unset.
- Searched `ui/src` and `ui/storybook` for the removed training routes,
drawer, library, badges, and actions. Only negative regression
assertions remain.

## Risks

- Low implementation risk. This change deletes UI-only entry points and
does not change the database or server APIs.
- Saved training-page URLs no longer render a board route. This is the
intended behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, model `gpt-5.6-sol`, with `xhigh` reasoning. The runtime
did not expose the context-window size. The agent used repository tools,
shell execution, and automated tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-11 14:23:57 -04:00

264 lines
9.2 KiB
TypeScript

// @vitest-environment jsdom
import type { 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";
import { CloudAccessGate } from "./components/CloudAccessGate";
import appSource from "./App.tsx?raw";
const mockHealthApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
getCurrentBoardAccess: vi.fn(),
claimBootstrapAdmin: vi.fn(),
}));
vi.mock("./api/health", () => ({
healthApi: mockHealthApi,
}));
vi.mock("./api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("./api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children?: ReactNode }) => <a href={to}>{children}</a>,
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
Outlet: () => <div>Outlet content</div>,
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
useParams: () => ({}),
}));
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function waitForText(container: HTMLElement, text: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (container.textContent?.includes(text)) return;
await flushReact();
}
expect(container.textContent).toContain(text);
}
function renderGate(container: HTMLElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudAccessGate />
</QueryClientProvider>,
);
});
return root;
}
function unmountRoot(root: ReturnType<typeof createRoot>) {
flushSync(() => {
root.unmount();
});
}
describe("CloudAccessGate", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "ready",
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows a no-access message for signed-in users without org access", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
source: "session",
keyId: null,
});
const root = renderGate(container);
await waitForText(container, "No company access");
expect(container.textContent).toContain("No company access");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
it("allows authenticated users with company access through to the board", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: ["company-1"],
source: "session",
keyId: null,
});
const root = renderGate(container);
await waitForText(container, "Outlet content");
expect(container.textContent).toContain("Outlet content");
expect(container.textContent).not.toContain("No company access");
unmountRoot(root);
});
it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: false,
});
mockAuthApi.getSession.mockResolvedValue(null);
const root = renderGate(container);
await waitForText(container, "Finish setting up this Paperclip");
expect(container.textContent).toContain("Finish setting up this Paperclip");
expect(container.textContent).toContain("Sign in / Create account");
expect(container.textContent).toContain("pnpm paperclipai auth bootstrap-ceo");
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
unmountRoot(root);
});
it("shows the claim action for signed-in private bootstrap-pending instances", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: false,
});
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" });
const root = renderGate(container);
await waitForText(container, "Claim this instance");
expect(container.textContent).toContain("Claim this instance");
expect(container.textContent).toContain("Signed in as user@example.com");
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("Claim this instance"),
);
expect(button).toBeTruthy();
flushSync(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await waitForText(container, "You're the instance admin");
expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("You're the instance admin");
expect(container.textContent).toContain("Continue to dashboard");
unmountRoot(root);
});
it("keeps public bootstrap-pending instances invite-only", async () => {
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
bootstrapStatus: "bootstrap_pending",
bootstrapInviteActive: true,
});
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
const root = renderGate(container);
await waitForText(container, "This Paperclip is waiting on its first admin");
expect(container.textContent).toContain("This Paperclip is waiting on its first admin");
expect(container.textContent).toContain("invite-only mode");
expect(container.textContent).not.toContain("Claim this instance");
expect(container.textContent).not.toContain("Sign in / Create account");
expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled();
unmountRoot(root);
});
});
describe("Skill Studio routes", () => {
it("registers create mode before the skillId route in prefixed and unprefixed routing", () => {
const createRoute = 'path="skills/studio/new"';
const detailRoute = 'path="skills/studio/:skillId"';
const createIndexes = [...appSource.matchAll(new RegExp(createRoute, "g"))].map((match) => match.index ?? -1);
const detailIndexes = [...appSource.matchAll(new RegExp(detailRoute, "g"))].map((match) => match.index ?? -1);
expect(createIndexes).toHaveLength(2);
expect(detailIndexes).toHaveLength(2);
expect(createIndexes[0]).toBeLessThan(detailIndexes[0]!);
expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!);
});
});
describe("Apps routes", () => {
it("uses browse as the Apps landing page and gives connections a canonical URL", () => {
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
});
});
describe("Decisions routes", () => {
it("does not register decision-training views", () => {
expect(appSource).not.toContain('path="decisions/training"');
expect(appSource).not.toContain('path="decisions/training/:id"');
expect(appSource).not.toContain('path="training"');
expect(appSource).not.toContain('path="training/:id"');
});
});