From 145d86911b323916ace4d99d25695247f7d22365 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:23:57 -0400 Subject: [PATCH] Remove decision training UI (#11225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/App.test.tsx | 9 + ui/src/App.tsx | 10 - ui/src/api/decisionTraining.ts | 54 -- ui/src/components/AttentionQueueRow.test.tsx | 81 +-- ui/src/components/AttentionQueueRow.tsx | 56 -- ui/src/components/DecisionShelf.tsx | 3 - .../DecisionTrainingDrawer.test.tsx | 300 ---------- ui/src/components/DecisionTrainingDrawer.tsx | 521 ------------------ ui/src/components/DecisionsToolbar.tsx | 18 +- ui/src/lib/decisionTraining.test.ts | 12 - ui/src/lib/decisionTraining.ts | 50 -- ui/src/lib/queryKeys.ts | 4 - ui/src/pages/DecisionQueuePage.tsx | 28 +- ui/src/pages/Training.test.tsx | 64 --- ui/src/pages/Training.tsx | 221 -------- ui/src/pages/WhatNeedsMe.tsx | 22 - .../stories/decisions-desk.stories.tsx | 2 +- 17 files changed, 16 insertions(+), 1439 deletions(-) delete mode 100644 ui/src/api/decisionTraining.ts delete mode 100644 ui/src/components/DecisionTrainingDrawer.test.tsx delete mode 100644 ui/src/components/DecisionTrainingDrawer.tsx delete mode 100644 ui/src/lib/decisionTraining.test.ts delete mode 100644 ui/src/lib/decisionTraining.ts delete mode 100644 ui/src/pages/Training.test.tsx delete mode 100644 ui/src/pages/Training.tsx diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index f1d6fb1c2e..4383317913 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -252,3 +252,12 @@ describe("Apps routes", () => { expect(appSource).toContain('} />'); }); }); + +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"'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 31a7107086..5582747ee3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -44,7 +44,6 @@ import { CompanyActivity } from "./pages/audit/CompanyActivity"; import { Inbox } from "./pages/Inbox"; import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { DecisionQueuePage } from "./pages/DecisionQueuePage"; -import { TrainingInspector, TrainingLibrary } from "./pages/Training"; import { BoardChat } from "./pages/BoardChat"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; @@ -281,10 +280,6 @@ function boardRoutes() { ) : null} } /> } /> - } /> - } /> - } /> - } /> } /> } /> } /> @@ -312,11 +307,6 @@ function InboxRootRedirect() { return ; } -function LegacyTrainingRedirect() { - const { id } = useParams<{ id: string }>(); - return ; -} - function LegacySkillStudioRedirect() { const location = useLocation(); const { companies, selectedCompany, loading } = useCompany(); diff --git a/ui/src/api/decisionTraining.ts b/ui/src/api/decisionTraining.ts deleted file mode 100644 index fb060442bc..0000000000 --- a/ui/src/api/decisionTraining.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { - DecisionTrainingExample, - DecisionTrainingPreview, - DecisionTrainingSourceKind, -} from "@paperclipai/shared"; -import { api, type RequestOptions } from "./client"; - -export interface DecisionTrainingListItem { - example: DecisionTrainingExample; - issueTitle: string; - issueIdentifier: string; -} - -export interface DecisionTrainingFilters { - project?: string; - kind?: DecisionTrainingSourceKind; - author?: string; - q?: string; -} - -/** The durable (source + issue) target a training example anchors to. */ -export interface DecisionTrainingTarget { - sourceKind: DecisionTrainingSourceKind; - sourceId: string; - issueId: string; -} - -export const decisionTrainingApi = { - list: (companyId: string, filters: DecisionTrainingFilters = {}, options?: RequestOptions) => { - const params = new URLSearchParams(); - if (filters.project) params.set("project", filters.project); - if (filters.kind) params.set("kind", filters.kind); - if (filters.author) params.set("author", filters.author); - if (filters.q) params.set("q", filters.q); - const query = params.toString(); - return api.get( - `/companies/${companyId}/decision-training${query ? `?${query}` : ""}`, - options, - ); - }, - get: (id: string, options?: RequestOptions) => - api.get(`/decision-training/${id}`, options), - /** - * Preview the state a new example would freeze, without persisting it. Powers - * the create drawer's "state frozen with this example" panel. - */ - preview: (companyId: string, target: DecisionTrainingTarget) => - api.post(`/companies/${companyId}/decision-training/preview`, target), - create: (companyId: string, input: DecisionTrainingTarget & { notes: string }) => - api.post(`/companies/${companyId}/decision-training`, input), - updateNotes: (id: string, notes: string) => - api.patch(`/decision-training/${id}`, { notes }), - delete: (id: string) => api.delete(`/decision-training/${id}`), -}; diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 56b7bae0ea..f6ba608fc3 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -742,94 +742,19 @@ describe("AttentionQueueRow", () => { expect(gallery?.querySelectorAll("a")).toHaveLength(0); }); - // Decision training (PAP-14299): a trainable row shows the train affordance; - // the trained/untrained state renders purely from `trainingExampleId`. - function trainableItem(overrides: Partial = {}): AttentionItem { - return buildItem({ - sourceKind: "issue_thread_interaction", - subject: { - kind: "interaction", - id: "interaction-1", - companyId: "c1", - title: "Approve the migration plan?", - identifier: null, - status: "pending", - href: "/PAP/issues/PAP-1", - metadata: { issueId: "issue-1", kind: "request_confirmation" }, - }, - ...overrides, - }); - } - - // Training lives in the row's overflow menu AND on a visible inline "Train" - // pill for untrained rows. Menu items live in a portal that - // only mounts once opened — environment-flaky in jsdom (see the dismiss test - // above) — so the untrained path asserts the menu exists, no trained badge is - // shown, and the onTrain contract is exercised through the visible pill. - it("shows a visible Train pill (no trained badge) and fires onTrain when clicked", () => { - const onTrain = vi.fn(); + it("does not surface training state or actions for decisions", () => { render( , ); - expect(container?.querySelector('[aria-label="Row actions"]')).toBeTruthy(); + expect(container?.textContent).not.toContain("Train"); expect(container?.querySelector('[data-testid="attention-trained-badge"]')).toBeNull(); - const trainPill = container?.querySelector('[data-testid="attention-train-inline"]'); - expect(trainPill?.textContent).toContain("Train"); - act(() => trainPill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - expect(onTrain).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); - }); - - it("hides the visible Train pill once trained (the badge stands in for it)", () => { - render( - , - ); expect(container?.querySelector('[data-testid="attention-train-inline"]')).toBeNull(); - expect(container?.querySelector('[data-testid="attention-trained-badge"]')).toBeTruthy(); - }); - - it("renders a Trained ✓ badge once trained and fires onTrain when it is clicked", () => { - const onTrain = vi.fn(); - render( - , - ); - const badge = container?.querySelector('[data-testid="attention-trained-badge"]'); - expect(badge?.textContent).toContain("Trained"); - act(() => badge?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - expect(onTrain).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); - }); - - it("does not offer training on a decision that isn't anchored to an issue", () => { - render( - , - ); expect(container?.querySelector('[data-testid="attention-train-button"]')).toBeNull(); }); }); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index 91037c27be..f8943521f6 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -6,7 +6,6 @@ import { ChevronDown, ChevronUp, ExternalLink, - GraduationCap, Loader2, MoreHorizontal, RotateCcw, @@ -29,7 +28,6 @@ import { isInlineResolvable, sourceMeta, } from "../lib/attention"; -import { isTrainable } from "../lib/decisionTraining"; import { cn, relativeTime } from "../lib/utils"; import { DecisionTriageStrip } from "./DecisionTriageStrip"; import { StatusGlyph } from "./StatusGlyph"; @@ -83,8 +81,6 @@ interface AttentionQueueRowProps { onToggleExpand: (item: AttentionItem) => void; onDismiss: (item: AttentionItem) => void; onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; - /** Open the decision-training drawer for this row (create or view). */ - onTrain?: (item: AttentionItem) => void; /** Restore a snoozed/dismissed row (curtain variant only). */ onRestore?: (item: AttentionItem) => void; /** "active" renders the live queue row; "hidden" renders a curtain row. */ @@ -113,7 +109,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ onToggleExpand, onDismiss, onSnooze, - onTrain, onRestore, variant = "active", agentMap, @@ -146,12 +141,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ // with no triage keep the explicit Open button and never toggle on a stray click. const triageEnabled = showTriage && !isHidden; const expandable = inline || (!isHidden && hasImages) || triageEnabled; - // Any issue-anchored approval or interaction is - // trainable at any time (pending or resolved). Trained/untrained renders - // purely from the feed's `trainingExampleId` — no per-row fetch. - const trainable = !isHidden && !!onTrain && isTrainable(item); - const trained = item.trainingExampleId != null; - const activate = () => { if (expandable) onToggleExpand(item); }; @@ -284,38 +273,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ )} - {trainable && trained && ( - - )} - {/* Visible train affordance for untrained rows. Trained - rows already carry the "Trained ✓" badge above; both surfaces also - keep the overflow "Train this decision" entry. Sits in the same slot - as the badge so a row's training state reads from one place. */} - {trainable && !trained && ( - - )}
@@ -342,19 +299,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ - {/* Training moved off the header strip (which now carries only - recency + overflow) but keeps its testids so the affordance - is still addressable. */} - {trainable && ( - onTrain?.(item)} - > - - {trained ? "View training example" : "Train this decision"} - - )} {onSnooze && onSnooze(item, iso)} />} onDismiss(item)}> diff --git a/ui/src/components/DecisionShelf.tsx b/ui/src/components/DecisionShelf.tsx index a5a05807dd..addfb35e81 100644 --- a/ui/src/components/DecisionShelf.tsx +++ b/ui/src/components/DecisionShelf.tsx @@ -58,7 +58,6 @@ export function AgingItemRow({ onToggleExpand, onDismiss, onSnooze, - onTrain, }: { item: AttentionItem; companyId: string; @@ -70,7 +69,6 @@ export function AgingItemRow({ onToggleExpand: (item: AttentionItem) => void; onDismiss: (item: AttentionItem) => void; onSnooze: (item: AttentionItem, snoozedUntil: string) => void; - onTrain: (item: AttentionItem) => void; }) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -115,7 +113,6 @@ export function AgingItemRow({ onToggleExpand={onToggleExpand} onDismiss={onDismiss} onSnooze={onSnooze} - onTrain={onTrain} agentMap={agentMap} agents={agents} showTriage diff --git a/ui/src/components/DecisionTrainingDrawer.test.tsx b/ui/src/components/DecisionTrainingDrawer.test.tsx deleted file mode 100644 index b34cf79502..0000000000 --- a/ui/src/components/DecisionTrainingDrawer.test.tsx +++ /dev/null @@ -1,300 +0,0 @@ -// @vitest-environment jsdom - -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 type { - AttentionItem, - DecisionTrainingExample, - DecisionTrainingPreview, -} from "@paperclipai/shared"; - -const mockApi = vi.hoisted(() => ({ - preview: vi.fn(), - create: vi.fn(), - get: vi.fn(), - updateNotes: vi.fn(), - delete: vi.fn(), -})); - -vi.mock("../api/decisionTraining", async (importOriginal) => { - const original = await importOriginal(); - return { ...original, decisionTrainingApi: mockApi }; -}); - -vi.mock("@/lib/router", () => ({ - Link: ({ children, to, ...props }: React.ComponentProps<"a"> & { to: string }) => ( - {children} - ), -})); - -(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -import { DecisionTrainingDrawer } from "./DecisionTrainingDrawer"; -import { ToastProvider } from "../context/ToastContext"; - -function act(callback: () => void | Promise) { - let result: void | Promise | undefined; - flushSync(() => { - result = callback(); - }); - return result; -} - -async function waitFor(predicate: () => boolean, attempts = 40): Promise { - for (let i = 0; i < attempts; i += 1) { - if (predicate()) return; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)); - }); - } - throw new Error("waitFor predicate did not become true"); -} - -function setTextareaValue(el: HTMLTextAreaElement, value: string) { - const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!; - setter.call(el, value); - el.dispatchEvent(new Event("input", { bubbles: true })); -} - -/** Text query across the whole document (Radix portals content into body). */ -function bodyText(): string { - return document.body.textContent ?? ""; -} - -function findButton(label: string): HTMLButtonElement | null { - return [...document.body.querySelectorAll("button")].find( - (b) => (b.textContent ?? "").trim().includes(label), - ) as HTMLButtonElement | null; -} - -/** Buttons or anchors (asChild renders the trigger as its child element). */ -function findClickable(label: string): HTMLElement | null { - return [...document.body.querySelectorAll("button, a")].find( - (el) => (el.textContent ?? "").trim().includes(label), - ) as HTMLElement | null; -} - -function buildItem(overrides: Partial = {}): AttentionItem { - return { - id: "row-1", - companyId: "c1", - sourceKind: "issue_thread_interaction", - subject: { - kind: "interaction", - id: "interaction-1", - companyId: "c1", - title: "Approve the migration plan?", - identifier: null, - status: "pending", - href: "/tasks/task-1", - metadata: { issueId: "issue-1", kind: "request_confirmation" }, - }, - whyNow: "", - decisionVerbs: [], - inlineResolvable: true, - entryRule: "", - exitRule: "", - dedupKey: "interaction:interaction-1", - dismissalKey: "attention:interaction:interaction-1", - severity: "medium", - rank: 0, - activityAt: "2026-07-09T12:00:00Z", - createdAt: "2026-07-09T12:00:00Z", - updatedAt: "2026-07-09T12:00:00Z", - relatedIssue: null, - project: null, - workspace: null, - detail: null, - dismissal: null, - ...overrides, - expiresAt: overrides.expiresAt ?? null, - ruleKey: overrides.ruleKey ?? null, - originAgentName: overrides.originAgentName ?? null, - queues: overrides.queues ?? [], - shelf: overrides.shelf ?? false, - retentionDays: overrides.retentionDays ?? 30, - keep: overrides.keep ?? false, - archivedAt: overrides.archivedAt ?? null, - retentionVersion: overrides.retentionVersion ?? 1, - decideBy: overrides.decideBy ?? null, - decideByAttribution: overrides.decideByAttribution ?? null, - snoozedUntil: overrides.snoozedUntil ?? null, - trainingExampleId: overrides.trainingExampleId ?? null, - }; -} - -function buildSnapshot(): DecisionTrainingExample["snapshot"] { - return { - version: 1, - capturedAt: "2026-07-10T00:00:00Z", - cutoff: { at: "2026-07-10T00:00:00Z", lastCommentId: "comment-abcdef12", commentCount: 3 }, - issue: {}, - comments: [{}, {}, {}], - runs: [{}, {}], - decision: { kind: "interaction", payload: {}, actor: null, outcome: "accepted" }, - code: { repoUrl: "r", ref: "main", commitSha: "0123456789abcdef", resolution: "exact" }, - }; -} - -function buildPreview(): DecisionTrainingPreview { - return { cutoffAt: "2026-07-10T00:00:00Z", decisionOutcome: "accepted", snapshot: buildSnapshot() }; -} - -function buildExample(overrides: Partial = {}): DecisionTrainingExample { - return { - id: "example-1", - companyId: "c1", - sourceKind: "interaction", - sourceId: "interaction-1", - issueId: "issue-1", - cutoffAt: "2026-07-10T00:00:00Z", - notes: "I accepted because the plan covered rollback.", - notesHistory: [], - decisionOutcome: "accepted", - snapshot: buildSnapshot(), - createdByUserId: "user-1", - createdAt: "2026-07-10T00:00:00Z", - updatedAt: "2026-07-10T00:00:00Z", - ...overrides, - retentionPolicy: overrides.retentionPolicy ?? "scrub_deleted_comments_v1", - }; -} - -let container: HTMLDivElement; -let root: ReturnType; - -function render(node: React.ReactNode) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, - }); - root = createRoot(container); - act(() => { - root.render( - - {node} - , - ); - }); -} - -beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - Object.values(mockApi).forEach((fn) => fn.mockReset()); -}); - -afterEach(() => { - act(() => root?.unmount()); - container.remove(); - document.body.innerHTML = ""; -}); - -describe("DecisionTrainingDrawer — create state", () => { - it("previews the frozen snapshot and saves an example", async () => { - mockApi.preview.mockResolvedValue(buildPreview()); - mockApi.create.mockResolvedValue(buildExample()); - - render( - {}} companyId="c1" item={buildItem()} />, - ); - - await waitFor(() => bodyText().includes("before cutoff")); - - // Snapshot preview surfaces cutoff, comment/run counts and the commit. - expect(bodyText()).toContain("3 · last comment-"); - expect(bodyText()).toContain("2 before cutoff"); - expect(bodyText()).toContain("0123456789"); - expect(bodyText()).toContain("Resolved · accepted"); - - const textarea = document.body.querySelector("textarea") as HTMLTextAreaElement; - expect(textarea).toBeTruthy(); - act(() => setTextareaValue(textarea, "Trusting the rollback plan.")); - - await act(() => findButton("Save example")?.click()); - await waitFor(() => mockApi.create.mock.calls.length > 0); - - expect(mockApi.create).toHaveBeenCalledWith("c1", { - sourceKind: "interaction", - sourceId: "interaction-1", - issueId: "issue-1", - notes: "Trusting the rollback plan.", - }); - }); - - it("refuses to train a decision with no issue anchor", () => { - const item = buildItem({ subject: { ...buildItem().subject, metadata: {} }, relatedIssue: null }); - render( {}} companyId="c1" item={item} />); - expect(bodyText()).toContain("isn't anchored to an issue"); - expect(mockApi.preview).not.toHaveBeenCalled(); - }); -}); - -describe("DecisionTrainingDrawer — saved state", () => { - it("shows provenance and a read-only snapshot, and round-trips notes edits", async () => { - mockApi.get.mockResolvedValue(buildExample()); - mockApi.updateNotes.mockResolvedValue(buildExample({ notes: "Revised reasoning.", updatedAt: "2026-07-11T00:00:00Z" })); - - render( - {}} - companyId="c1" - item={buildItem({ trainingExampleId: "example-1" })} - currentUserId="user-1" - />, - ); - - expect(bodyText()).toContain("Training example"); - expect(bodyText()).not.toContain("Train this decision"); - expect(mockApi.preview).not.toHaveBeenCalled(); - await waitFor(() => bodyText().includes("Frozen state")); - expect(bodyText()).toContain("You"); // provenance author - expect(bodyText()).toContain("Read-only"); // snapshot is visibly read-only - expect(bodyText()).toContain("I accepted because the plan covered rollback."); - expect(findClickable("Open full record")).toBeTruthy(); - - await act(() => findButton("Edit")?.click()); - const textarea = document.body.querySelector("textarea") as HTMLTextAreaElement; - expect(textarea).toBeTruthy(); - act(() => setTextareaValue(textarea, "Revised reasoning.")); - await act(() => findButton("Save notes")?.click()); - await waitFor(() => mockApi.updateNotes.mock.calls.length > 0); - - expect(mockApi.updateNotes).toHaveBeenCalledWith("example-1", "Revised reasoning."); - }); - - it("deletes after confirmation", async () => { - mockApi.get.mockResolvedValue(buildExample()); - mockApi.delete.mockResolvedValue(undefined); - const onOpenChange = vi.fn(); - - render( - , - ); - - await waitFor(() => bodyText().includes("Frozen state")); - await act(() => { - findButton("Delete")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - }); - // Confirm dialog action. - await waitFor(() => bodyText().includes("Delete this training example?")); - const confirm = document.body.querySelector( - '[data-slot="alert-dialog-action"]', - ); - expect(confirm).toBeTruthy(); - await act(() => { - confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - }); - await waitFor(() => mockApi.delete.mock.calls.length > 0); - - expect(mockApi.delete).toHaveBeenCalledWith("example-1"); - }); -}); diff --git a/ui/src/components/DecisionTrainingDrawer.tsx b/ui/src/components/DecisionTrainingDrawer.tsx deleted file mode 100644 index 7e31f83b1e..0000000000 --- a/ui/src/components/DecisionTrainingDrawer.tsx +++ /dev/null @@ -1,521 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Clock, - ExternalLink, - FileText, - GitCommitHorizontal, - GraduationCap, - Loader2, - Lock, - MessageSquare, - Pencil, - Play, - Trash2, -} from "lucide-react"; -import type { AttentionItem, DecisionTrainingExample } from "@paperclipai/shared"; -import { Link } from "@/lib/router"; -import { decisionTrainingApi, type DecisionTrainingTarget } from "../api/decisionTraining"; -import { useToastActions } from "../context/ToastContext"; -import { queryKeys } from "../lib/queryKeys"; -import { codeResolutionLabel, decisionTrainingHref, trainingTargetForItem } from "../lib/decisionTraining"; -import { relativeTime } from "../lib/utils"; -import { Button } from "./ui/button"; -import { Textarea } from "./ui/textarea"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "./ui/sheet"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "./ui/alert-dialog"; - -const NOTES_PLACEHOLDER = - "How you thought about it, what signals mattered, what you decided and why…"; - -interface DecisionTrainingDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - companyId: string; - /** The Decisions row being trained; null when the drawer is closed. */ - item: AttentionItem | null; - currentUserId?: string | null; -} - -/** - * Right-hand drawer for capturing a decision-training example from a Decisions - * row. Renders a create state (immutable snapshot preview + notes) - * for untrained decisions and a saved state (provenance, editable notes, - * read-only snapshot, delete) once an example exists. Write affordances are - * only ever mounted for human users — agents never see this drawer. - */ -export function DecisionTrainingDrawer({ - open, - onOpenChange, - companyId, - item, - currentUserId, -}: DecisionTrainingDrawerProps) { - const [createdExample, setCreatedExample] = useState<{ - itemId: string; - exampleId: string; - } | null>(null); - const locallyCreatedExampleId = createdExample && createdExample.itemId === item?.id - ? createdExample.exampleId - : null; - const savedExampleId = item?.trainingExampleId ?? locallyCreatedExampleId; - - const target = item ? trainingTargetForItem(item) : null; - - return ( - - - - - - {savedExampleId ? "Training example" : "Train this decision"} - - - {savedExampleId - ? "The frozen state is read-only; your notes stay editable." - : "Freeze this decision's state and record how you'd want it decided."} - - - - {!item || !target ? ( -
- This decision can't be trained — it isn't anchored to an issue. -
- ) : savedExampleId ? ( - { - setCreatedExample(null); - onOpenChange(false); - }} - /> - ) : ( - setCreatedExample({ itemId: item.id, exampleId: example.id })} - onCancel={() => onOpenChange(false)} - /> - )} -
-
- ); -} - -function CreateState({ - companyId, - item, - target, - onCreated, - onCancel, -}: { - companyId: string; - item: AttentionItem; - target: DecisionTrainingTarget; - onCreated: (example: DecisionTrainingExample) => void; - onCancel: () => void; -}) { - const queryClient = useQueryClient(); - const { pushToast } = useToastActions(); - const [notes, setNotes] = useState(""); - - const preview = useQuery({ - queryKey: [...queryKeys.decisionTraining.list(companyId), "preview", target.sourceKind, target.sourceId, target.issueId], - queryFn: () => decisionTrainingApi.preview(companyId, target), - enabled: true, - }); - - const create = useMutation({ - mutationFn: () => decisionTrainingApi.create(companyId, { ...target, notes: notes.trim() }), - onSuccess: (example) => { - queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.decisionTraining.list(companyId) }); - pushToast({ title: "Decision trained", tone: "success" }); - onCreated(example); - }, - onError: (error) => { - pushToast({ - title: "Could not train this decision", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }); - }, - }); - - return ( -
-
- - -
- -