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>
This commit is contained in:
Dotta 2026-08-11 14:23:57 -04:00 committed by GitHub
parent 45dfb183b6
commit 145d86911b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 16 additions and 1439 deletions

View File

@ -252,3 +252,12 @@ describe("Apps routes", () => {
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"');
});
});

View File

@ -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}
<Route path="decisions" element={<WhatNeedsMe />} />
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />
<Route path="decisions/training" element={<TrainingLibrary />} />
<Route path="decisions/training/:id" element={<TrainingInspector />} />
<Route path="training" element={<Navigate to="/decisions/training" replace />} />
<Route path="training/:id" element={<LegacyTrainingRedirect />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
@ -312,11 +307,6 @@ function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function LegacyTrainingRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={id ? `/decisions/training/${id}` : "/decisions/training"} replace />;
}
function LegacySkillStudioRedirect() {
const location = useLocation();
const { companies, selectedCompany, loading } = useCompany();

View File

@ -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<DecisionTrainingListItem[]>(
`/companies/${companyId}/decision-training${query ? `?${query}` : ""}`,
options,
);
},
get: (id: string, options?: RequestOptions) =>
api.get<DecisionTrainingExample>(`/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<DecisionTrainingPreview>(`/companies/${companyId}/decision-training/preview`, target),
create: (companyId: string, input: DecisionTrainingTarget & { notes: string }) =>
api.post<DecisionTrainingExample>(`/companies/${companyId}/decision-training`, input),
updateNotes: (id: string, notes: string) =>
api.patch<DecisionTrainingExample>(`/decision-training/${id}`, { notes }),
delete: (id: string) => api.delete<void>(`/decision-training/${id}`),
};

View File

@ -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> = {}): 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(
<AttentionQueueRow
item={trainableItem()}
item={buildItem({ trainingExampleId: "example-1" })}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
onTrain={onTrain}
/>,
);
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(
<AttentionQueueRow
item={trainableItem({ trainingExampleId: "example-1" })}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
onTrain={noop}
/>,
);
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(
<AttentionQueueRow
item={trainableItem({ trainingExampleId: "example-1" })}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
onTrain={onTrain}
/>,
);
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(
<AttentionQueueRow
item={buildItem({ subject: { ...buildItem().subject, metadata: {} }, relatedIssue: null })}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
onTrain={noop}
/>,
);
expect(container?.querySelector('[data-testid="attention-train-button"]')).toBeNull();
});
});

View File

@ -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({
</span>
</>
)}
{trainable && trained && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-1.5 py-px text-(length:--text-nano) font-medium text-primary hover:bg-primary/15"
onClick={(event) => {
event.stopPropagation();
onTrain?.(item);
}}
data-testid="attention-trained-badge"
>
<GraduationCap className="h-3 w-3 fill-primary/25" />
Trained
</button>
)}
{/* 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 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-px text-(length:--text-nano) font-medium text-muted-foreground hover:border-primary/40 hover:text-primary"
onClick={(event) => {
event.stopPropagation();
onTrain?.(item);
}}
data-testid="attention-train-inline"
>
<GraduationCap className="h-3 w-3" />
Train
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
@ -342,19 +299,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Training moved off the header strip (which now carries only
recency + overflow) but keeps its testids so the affordance
is still addressable. */}
{trainable && (
<DropdownMenuItem
data-training-state={trained ? "trained" : "untrained"}
data-testid="attention-train-button"
onClick={() => onTrain?.(item)}
>
<GraduationCap className={cn("h-4 w-4", trained && "fill-primary/25")} />
{trained ? "View training example" : "Train this decision"}
</DropdownMenuItem>
)}
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />

View File

@ -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

View File

@ -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<typeof import("../api/decisionTraining")>();
return { ...original, decisionTrainingApi: mockApi };
});
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: React.ComponentProps<"a"> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
(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<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
async function waitFor(predicate: () => boolean, attempts = 40): Promise<void> {
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> = {}): 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> = {}): 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<typeof createRoot>;
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
root = createRoot(container);
act(() => {
root.render(
<ToastProvider>
<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>
</ToastProvider>,
);
});
}
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(
<DecisionTrainingDrawer open onOpenChange={() => {}} 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(<DecisionTrainingDrawer open onOpenChange={() => {}} 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(
<DecisionTrainingDrawer
open
onOpenChange={() => {}}
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(
<DecisionTrainingDrawer
open
onOpenChange={onOpenChange}
companyId="c1"
item={buildItem({ trainingExampleId: "example-1" })}
currentUserId="user-1"
/>,
);
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<HTMLButtonElement>(
'[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");
});
});

View File

@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="w-full gap-0 p-0 sm:max-w-lg"
data-testid="decision-training-drawer"
>
<SheetHeader className="border-b border-border">
<SheetTitle className="flex items-center gap-2">
<GraduationCap className="size-4 text-muted-foreground" />
{savedExampleId ? "Training example" : "Train this decision"}
</SheetTitle>
<SheetDescription>
{savedExampleId
? "The frozen state is read-only; your notes stay editable."
: "Freeze this decision's state and record how you'd want it decided."}
</SheetDescription>
</SheetHeader>
{!item || !target ? (
<div className="p-4 text-sm text-muted-foreground">
This decision can't be trained — it isn't anchored to an issue.
</div>
) : savedExampleId ? (
<SavedState
exampleId={savedExampleId}
companyId={companyId}
currentUserId={currentUserId}
onDeleted={() => {
setCreatedExample(null);
onOpenChange(false);
}}
/>
) : (
<CreateState
companyId={companyId}
item={item}
target={target}
onCreated={(example) => setCreatedExample({ itemId: item.id, exampleId: example.id })}
onCancel={() => onOpenChange(false)}
/>
)}
</SheetContent>
</Sheet>
);
}
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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
<DecisionContext item={item} outcome={preview.data?.decisionOutcome ?? null} />
<section className="space-y-2">
<label htmlFor="training-notes" className="text-sm font-medium text-foreground">
Your notes
</label>
<Textarea
id="training-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder={NOTES_PLACEHOLDER}
className="min-h-40 text-sm"
/>
</section>
<SnapshotPreview
heading="State frozen with this example"
snapshot={preview.data?.snapshot ?? null}
cutoffAt={preview.data?.cutoffAt ?? null}
loading={preview.isLoading}
error={preview.isError ? (preview.error as Error).message : null}
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-4">
<Button variant="ghost" onClick={onCancel} disabled={create.isPending}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={create.isPending || preview.isError}>
{create.isPending && <Loader2 className="size-4 animate-spin" />}
Save example
</Button>
</div>
</div>
);
}
function SavedState({
exampleId,
companyId,
currentUserId,
onDeleted,
}: {
exampleId: string;
companyId: string;
currentUserId?: string | null;
onDeleted: () => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [editing, setEditing] = useState(false);
const [draftNotes, setDraftNotes] = useState("");
const example = useQuery({
queryKey: queryKeys.decisionTraining.detail(exampleId),
queryFn: () => decisionTrainingApi.get(exampleId),
});
const saveNotes = useMutation({
mutationFn: () => decisionTrainingApi.updateNotes(exampleId, draftNotes.trim()),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.decisionTraining.detail(exampleId), updated);
queryClient.invalidateQueries({ queryKey: queryKeys.decisionTraining.list(companyId) });
pushToast({ title: "Notes updated", tone: "success" });
setEditing(false);
},
onError: (error) => {
pushToast({
title: "Could not update notes",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
const remove = useMutation({
mutationFn: () => decisionTrainingApi.delete(exampleId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.decisionTraining.list(companyId) });
pushToast({ title: "Training example deleted", tone: "info" });
onDeleted();
},
onError: (error) => {
pushToast({
title: "Could not delete example",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
if (example.isLoading) {
return (
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" /> Loading example
</div>
);
}
if (example.isError || !example.data) {
return (
<div className="p-4 text-sm text-destructive">
{example.isError ? (example.error as Error).message : "Example not found."}
</div>
);
}
const record = example.data;
const edited = record.updatedAt !== record.createdAt;
const authorLabel = currentUserId && record.createdByUserId === currentUserId
? "You"
: `User ${record.createdByUserId.slice(0, 8)}`;
const startEditing = () => {
setDraftNotes(record.notes);
setEditing(true);
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
{/* Provenance strip */}
<div
className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
data-testid="training-provenance"
>
<span className="font-medium text-foreground">{authorLabel}</span>
<span>·</span>
<span title={new Date(record.createdAt).toLocaleString()}>
Created {relativeTime(record.createdAt)}
</span>
{edited && (
<>
<span>·</span>
<span title={new Date(record.updatedAt).toLocaleString()}>
Edited {relativeTime(record.updatedAt)}
</span>
</>
)}
</div>
{/* Notes — the one editable surface */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Notes</span>
{!editing && (
<Button variant="ghost" size="xs" onClick={startEditing}>
<Pencil className="size-3.5" /> Edit
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draftNotes}
onChange={(event) => setDraftNotes(event.target.value)}
placeholder={NOTES_PLACEHOLDER}
className="min-h-40 text-sm"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={saveNotes.isPending}>
Cancel
</Button>
<Button size="sm" onClick={() => saveNotes.mutate()} disabled={saveNotes.isPending}>
{saveNotes.isPending && <Loader2 className="size-4 animate-spin" />}
Save notes
</Button>
</div>
</div>
) : record.notes ? (
<p className="whitespace-pre-wrap text-sm text-foreground">{record.notes}</p>
) : (
<p className="text-sm italic text-muted-foreground">No notes yet.</p>
)}
{record.notesHistory.length > 0 && (
<p className="text-(length:--text-nano) text-muted-foreground">
{record.notesHistory.length} previous {record.notesHistory.length === 1 ? "revision" : "revisions"}
</p>
)}
</section>
<SnapshotPreview
heading="Frozen state"
snapshot={record.snapshot}
cutoffAt={record.cutoffAt}
readOnly
exampleId={record.id}
/>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border p-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={remove.isPending}>
{remove.isPending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this training example?</AlertDialogTitle>
<AlertDialogDescription>
This removes the frozen snapshot and your notes. This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => remove.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button asChild variant="outline" size="sm">
<Link to={decisionTrainingHref(record.id)}>
Open full record
<ExternalLink className="size-3.5" />
</Link>
</Button>
</div>
</div>
);
}
/** Decision context block — what was decided (or that it's still pending). */
function DecisionContext({ item, outcome }: { item: AttentionItem; outcome: string | null }) {
return (
<section className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2" data-testid="training-context">
<p className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Decision
</p>
<p className="line-clamp-2 text-sm font-medium text-foreground">{item.subject.title ?? "Decision"}</p>
<p className="text-xs text-muted-foreground">
{outcome ? `Resolved · ${outcome}` : "Decision pending — cutoff will be now"}
</p>
</section>
);
}
/**
* Read-only preview of the captured snapshot. Shared by the create state (from
* the preview endpoint) and the saved state (from the persisted example). In the
* saved state it renders a visible read-only banner and per-section view links.
*/
function SnapshotPreview({
heading,
snapshot,
cutoffAt,
loading = false,
error = null,
readOnly = false,
exampleId,
}: {
heading: string;
snapshot: DecisionTrainingExample["snapshot"] | null;
cutoffAt: string | null;
loading?: boolean;
error?: string | null;
readOnly?: boolean;
exampleId?: string;
}) {
return (
<section className="space-y-2" data-testid="training-snapshot">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{heading}</span>
{readOnly && (
<span className="inline-flex items-center gap-1 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
<Lock className="size-3" /> Read-only
</span>
)}
</div>
{loading && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Capturing current state
</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{snapshot && (
<div className="divide-y divide-border rounded-md border border-border">
<SnapshotRow
icon={<Clock className="size-4" />}
label="Cutoff"
value={cutoffAt ? new Date(cutoffAt).toLocaleString() : "now"}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
<SnapshotRow
icon={<MessageSquare className="size-4" />}
label="Comments"
value={
snapshot.cutoff.commentCount === 0
? "None before cutoff"
: `${snapshot.cutoff.commentCount} · last ${snapshot.cutoff.lastCommentId?.slice(0, 8) ?? "—"}`
}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
<SnapshotRow
icon={<Play className="size-4" />}
label="Runs"
value={snapshot.runs.length === 0 ? "None before cutoff" : `${snapshot.runs.length} before cutoff`}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
<SnapshotRow
icon={<GitCommitHorizontal className="size-4" />}
label="Commit"
value={
snapshot.code.commitSha
? `${snapshot.code.commitSha.slice(0, 10)} · ${codeResolutionLabel(snapshot.code.resolution)}`
: codeResolutionLabel(snapshot.code.resolution)
}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
</div>
)}
</section>
);
}
function SnapshotRow({
icon,
label,
value,
href,
}: {
icon: React.ReactNode;
label: string;
value: string;
href?: string;
}) {
return (
<div className="flex items-center gap-3 px-3 py-2">
<span className="text-muted-foreground">{icon}</span>
<div className="min-w-0 flex-1">
<p className="text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">{label}</p>
<p className="truncate text-sm text-foreground">{value}</p>
</div>
{href && (
<Link
to={href}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<FileText className="size-3.5" /> View
</Link>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { type ReactNode } from "react";
import { ArrowUpDown, Check, GraduationCap, Layers, ListFilter } from "lucide-react";
import { ArrowUpDown, Check, Layers, ListFilter } from "lucide-react";
import {
ATTENTION_GROUP_BY_OPTIONS,
ATTENTION_SORT_OPTIONS,
@ -34,12 +34,10 @@ interface DecisionsToolbarProps {
onGroupByChange: (next: AttentionGroupBy) => void;
sortOrder: AttentionSortOrder;
onSortOrderChange: (next: AttentionSortOrder) => void;
/** Open the decision-training index. */
onOpenTraining: () => void;
}
/**
* The decisions filter / group / sort / training toolbar, shared verbatim by the
* The decisions filter / group / sort toolbar, shared verbatim by the
* desk (WhatNeedsMe) and the per-queue page so both surfaces expose an identical
* control set. All state lives in the parent; this is presentation
* plus the filter popover only.
@ -53,7 +51,6 @@ export function DecisionsToolbar({
onGroupByChange,
sortOrder,
onSortOrderChange,
onOpenTraining,
}: DecisionsToolbarProps) {
const activeFilterCount = countActiveAttentionFilters(filters);
return (
@ -114,17 +111,6 @@ export function DecisionsToolbar({
</div>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Training"
aria-label="Training"
onClick={onOpenTraining}
>
<GraduationCap className="h-3.5 w-3.5" />
</Button>
{/* Sort */}
<Popover>
<PopoverTrigger asChild>

View File

@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { decisionTrainingHref } from "./decisionTraining";
describe("decisionTrainingHref", () => {
it("keeps the training library under decisions", () => {
expect(decisionTrainingHref()).toBe("/decisions/training");
});
it("keeps training records under decisions", () => {
expect(decisionTrainingHref("example-1")).toBe("/decisions/training/example-1");
});
});

View File

@ -1,50 +0,0 @@
import type { AttentionItem, DecisionTrainingSnapshotV1 } from "@paperclipai/shared";
import type { DecisionTrainingTarget } from "../api/decisionTraining";
export function decisionTrainingHref(exampleId?: string): string {
return exampleId ? `/decisions/training/${exampleId}` : "/decisions/training";
}
/**
* Resolve the durable (source + issue) target a Decisions row would train
* against, or `null` when the row is not trainable.
*
* v1 trains two source kinds board approvals and issue-thread interactions
* and always anchors to the owning issue. An
* approval that is not linked to any issue has no durable anchor, so it is not
* trainable and returns `null`.
*/
export function trainingTargetForItem(item: AttentionItem): DecisionTrainingTarget | null {
const metadataIssueId = typeof item.subject.metadata?.issueId === "string"
? item.subject.metadata.issueId
: null;
const issueId = metadataIssueId ?? item.relatedIssue?.id ?? null;
if (!issueId) return null;
if (item.sourceKind === "issue_thread_interaction") {
return { sourceKind: "interaction", sourceId: item.subject.id, issueId };
}
if (item.sourceKind === "approval") {
return { sourceKind: "approval", sourceId: item.subject.id, issueId };
}
return null;
}
/** Whether a Decisions row should surface the train affordance at all. */
export function isTrainable(item: AttentionItem): boolean {
return trainingTargetForItem(item) !== null;
}
/** Human label for how confidently a code commit was resolved for the snapshot. */
export function codeResolutionLabel(resolution: DecisionTrainingSnapshotV1["code"]["resolution"]): string {
switch (resolution) {
case "exact":
return "exact (from the deciding run)";
case "nearest_run":
return "nearest run before cutoff";
case "workspace":
return "workspace default";
case "none":
return "no commit found";
}
}

View File

@ -398,10 +398,6 @@ export const queryKeys = {
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
attention: (companyId: string) => ["attention", companyId] as const,
decisionTraining: {
list: (companyId: string) => ["decision-training", companyId] as const,
detail: (id: string) => ["decision-training", "detail", id] as const,
},
decisions: {
list: (companyId: string, status?: string) =>
["decisions", companyId, status ?? "__all-statuses__"] as const,

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Settings2, X } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { useNavigate, useParams } from "@/lib/router";
import { useParams } from "@/lib/router";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@ -36,7 +36,6 @@ import {
type AttentionGroupBy,
type AttentionSortOrder,
} from "../lib/attention";
import { decisionTrainingHref } from "../lib/decisionTraining";
import { cn } from "../lib/utils";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
@ -44,7 +43,6 @@ import { DecisionsToolbar } from "../components/DecisionsToolbar";
import { Curtain, AgingItemRow } from "../components/DecisionShelf";
import { DecisionQueueRail } from "../components/DecisionQueueRail";
import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips";
import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
import { Button } from "../components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
@ -64,16 +62,12 @@ export function DecisionQueuePage() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const navigate = useNavigate();
const queryClient = useQueryClient();
const params = useParams<{ key: string }>();
const queueKey = params.key ?? "";
const [expandedId, setExpandedId] = useState<string | null>(null);
const { dismiss, snooze } = useInboxDismissals(selectedCompanyId);
// Decision-training drawer target. `null` when closed.
const [trainingItem, setTrainingItem] = useState<AttentionItem | null>(null);
// Toolbar preferences (persisted to localStorage, shared with the desk).
const [groupBy, setGroupBy] = useState<AttentionGroupBy>(() => loadAttentionGroupBy());
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
@ -200,10 +194,6 @@ export function DecisionQueuePage() {
const handleToggleExpand = useCallback((item: AttentionItem) => {
setExpandedId((prev) => (prev === item.id ? null : item.id));
}, []);
const handleTrain = useCallback((item: AttentionItem) => {
setTrainingItem(item);
}, []);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) });
@ -246,7 +236,6 @@ export function DecisionQueuePage() {
onGroupByChange={updateGroupBy}
sortOrder={sortOrder}
onSortOrderChange={updateSortOrder}
onOpenTraining={() => navigate(decisionTrainingHref())}
/>
</div>
@ -319,7 +308,6 @@ export function DecisionQueuePage() {
onToggleExpand={handleToggleExpand}
onDismiss={(next) => dismiss(next.dismissalKey)}
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
onTrain={handleTrain}
onExcluded={invalidate}
/>
))}
@ -353,23 +341,12 @@ export function DecisionQueuePage() {
onToggleExpand={handleToggleExpand}
onDismiss={(next) => dismiss(next.dismissalKey)}
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
onTrain={handleTrain}
/>
))}
</Curtain>
)}
</div>
)}
<DecisionTrainingDrawer
open={trainingItem !== null}
onOpenChange={(next) => {
if (!next) setTrainingItem(null);
}}
companyId={selectedCompanyId}
item={trainingItem}
currentUserId={currentUserId}
/>
</div>
);
}
@ -438,7 +415,6 @@ function QueueItemRow({
onToggleExpand,
onDismiss,
onSnooze,
onTrain,
onExcluded,
}: {
item: AttentionItem;
@ -451,7 +427,6 @@ function QueueItemRow({
onToggleExpand: (item: AttentionItem) => void;
onDismiss: (item: AttentionItem) => void;
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
onTrain: (item: AttentionItem) => void;
onExcluded: () => void;
}) {
const { pushToast } = useToastActions();
@ -524,7 +499,6 @@ function QueueItemRow({
onToggleExpand={onToggleExpand}
onDismiss={onDismiss}
onSnooze={onSnooze}
onTrain={onTrain}
agentMap={agentMap}
agents={agents}
showTriage

View File

@ -1,64 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import type { DecisionTrainingExample } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { TrainingThreadPanel, partitionTrainingThread } from "./Training";
const example: DecisionTrainingExample = {
id: "training-1",
companyId: "company-1",
sourceKind: "interaction",
sourceId: "interaction-1",
issueId: "issue-1",
cutoffAt: "2026-07-16T13:02:00.000Z",
notes: "Use the smaller change.",
notesHistory: [],
decisionOutcome: "approved",
retentionPolicy: "scrub_deleted_comments_v1",
snapshot: {
version: 1,
capturedAt: "2026-07-16T13:10:00.000Z",
cutoff: { at: "2026-07-16T13:02:00.000Z", lastCommentId: "before-2", commentCount: 2 },
issue: {},
comments: [
{ id: "before-1", body: "included", createdAt: "2026-07-16T12:00:00.000Z" },
{ id: "before-2", body: "last visible", createdAt: "2026-07-16T13:00:00.000Z" },
],
runs: [],
decision: { kind: "interaction", payload: {}, actor: null, outcome: "approved" },
code: { repoUrl: null, ref: null, commitSha: null, resolution: "none" },
},
createdByUserId: "local-board",
createdAt: "2026-07-16T13:10:00.000Z",
updatedAt: "2026-07-16T13:10:00.000Z",
};
const postCutoffComment = {
id: "after-1",
companyId: "company-1",
issueId: "issue-1",
body: "excluded tail",
authorType: "agent" as const,
authorAgentId: "agent-1",
authorUserId: null,
presentation: null,
metadata: null,
createdAt: new Date("2026-07-16T13:30:00.000Z"),
updatedAt: new Date("2026-07-16T13:30:00.000Z"),
};
describe("training cutoff rendering", () => {
it("keeps post-cutoff comments out of snapshot data and renders them ghosted for audit", () => {
const result = partitionTrainingThread(example.snapshot.comments, [...example.snapshot.comments, postCutoffComment], example.cutoffAt);
expect(result.included).toEqual(example.snapshot.comments);
expect(result.excluded).toEqual([postCutoffComment]);
expect(result.included).not.toContainEqual(postCutoffComment);
const markup = renderToStaticMarkup(<TrainingThreadPanel example={example} liveComments={[postCutoffComment]} />);
expect(markup).toContain("CUTOFF");
expect(markup).toContain("excluded tail");
expect(markup).toContain("Excluded from snapshot");
expect(markup).toContain('data-excluded-from-snapshot="true"');
expect(markup).toContain("opacity-50");
});
});

View File

@ -1,221 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DecisionTrainingExample, DecisionTrainingSourceKind, IssueComment, Project } from "@paperclipai/shared";
import { ArrowLeft, Download, Search } from "lucide-react";
import { useNavigate, useParams } from "@/lib/router";
import { decisionTrainingApi, type DecisionTrainingFilters } from "@/api/decisionTraining";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToastActions } from "@/context/ToastContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { decisionTrainingHref } from "@/lib/decisionTraining";
import { queryKeys } from "@/lib/queryKeys";
import { cn, formatDate, formatDateTime } from "@/lib/utils";
type SnapshotRecord = Record<string, unknown>;
function stringValue(record: SnapshotRecord | undefined, ...keys: string[]) {
for (const key of keys) {
const value = record?.[key];
if (typeof value === "string" && value.trim()) return value;
}
return null;
}
function recordDate(record: SnapshotRecord) {
const value = record.createdAt ?? record.created_at;
if (value instanceof Date) return value.toISOString();
return typeof value === "string" ? value : "";
}
function recordId(record: SnapshotRecord) {
return stringValue(record, "id") ?? "";
}
export function partitionTrainingThread(
snapshotComments: SnapshotRecord[],
liveComments: Array<SnapshotRecord | IssueComment>,
cutoffAt: string,
) {
const snapshotIds = new Set(snapshotComments.map(recordId).filter(Boolean));
const cutoffMs = new Date(cutoffAt).getTime();
const excluded = liveComments.filter((comment) => {
if (snapshotIds.has(recordId(comment as SnapshotRecord))) return false;
const createdMs = new Date(recordDate(comment as SnapshotRecord)).getTime();
return Number.isNaN(createdMs) || createdMs > cutoffMs;
});
return { included: snapshotComments, excluded };
}
function decisionTitle(example: DecisionTrainingExample, issueTitle?: string) {
const payload = example.snapshot.decision.payload;
return stringValue(payload, "title", "prompt", "summary", "action") ?? issueTitle ?? "Decision training example";
}
function outcomeLabel(value: string | null) {
if (!value) return "Pending at capture";
return value.replaceAll("_", " ");
}
function authorLabel(id: string) {
return id === "local-board" ? "Local board" : id.slice(0, 8);
}
function downloadExport(companyId: string) {
const anchor = document.createElement("a");
anchor.href = `/api/companies/${companyId}/decision-training/export.jsonl`;
anchor.download = "decision-training.jsonl";
anchor.hidden = true;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
export function TrainingLibrary() {
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [q, setQ] = useState("");
const [project, setProject] = useState("all");
const [kind, setKind] = useState("all");
const [author, setAuthor] = useState("all");
useEffect(() => setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: "Training" }]), [setBreadcrumbs]);
const filters = useMemo<DecisionTrainingFilters>(() => ({
q: q.trim() || undefined,
project: project === "all" ? undefined : project,
kind: kind === "all" ? undefined : kind as DecisionTrainingSourceKind,
author: author === "all" ? undefined : author,
}), [author, kind, project, q]);
const recordsQuery = useQuery({
queryKey: [...queryKeys.decisionTraining.list(selectedCompanyId ?? ""), filters],
queryFn: ({ signal }) => decisionTrainingApi.list(selectedCompanyId!, filters, { signal }),
enabled: Boolean(selectedCompanyId),
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId ?? "", { includeArchived: true }),
queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
enabled: Boolean(selectedCompanyId),
});
const records = recordsQuery.data ?? [];
const projects = projectsQuery.data ?? [];
const projectNames = new Map(projects.map((item: Project) => [item.id, item.name]));
const authors = [...new Set(records.map((row) => row.example.createdByUserId))];
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-bold">Training examples</h1>
<p className="mt-1 text-sm text-muted-foreground">Human decision traces with frozen state for future eval cases.</p>
</div>
<Button variant="outline" onClick={() => selectedCompanyId && downloadExport(selectedCompanyId)} disabled={!selectedCompanyId}>
<Download className="size-4" /> Export JSONL
</Button>
</header>
<div className="grid gap-3 md:grid-cols-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="Search training examples" value={q} onChange={(event) => setQ(event.target.value)} placeholder="Search notes, tasks…" className="pl-9" />
</div>
<Select value={project} onValueChange={setProject}><SelectTrigger aria-label="Filter by project"><SelectValue placeholder="Project: All" /></SelectTrigger><SelectContent><SelectItem value="all">Project: All</SelectItem>{projects.map((item: Project) => <SelectItem key={item.id} value={item.id}>{item.name}</SelectItem>)}</SelectContent></Select>
<Select value={kind} onValueChange={setKind}><SelectTrigger aria-label="Filter by decision kind"><SelectValue placeholder="Decision kind: All" /></SelectTrigger><SelectContent><SelectItem value="all">Decision kind: All</SelectItem><SelectItem value="interaction">Interaction</SelectItem><SelectItem value="approval">Approval</SelectItem><SelectItem value="execution_decision">Execution decision</SelectItem></SelectContent></Select>
<Select value={author} onValueChange={setAuthor}><SelectTrigger aria-label="Filter by author"><SelectValue placeholder="Author: All" /></SelectTrigger><SelectContent><SelectItem value="all">Author: All</SelectItem>{authors.map((userId) => <SelectItem key={userId} value={userId}>{authorLabel(userId)}</SelectItem>)}</SelectContent></Select>
</div>
{recordsQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading training examples</p> : null}
{recordsQuery.isError ? <p className="text-sm text-destructive">Could not load training examples.</p> : null}
{!recordsQuery.isLoading && !recordsQuery.isError && records.length === 0 ? <p className="py-12 text-center text-sm text-muted-foreground">No training examples match these filters.</p> : null}
<div className="overflow-hidden rounded-lg border border-border">
<div className="hidden grid-cols-6 gap-4 bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground md:grid">
<span>Decision</span><span>Outcome</span><span>Snapshot</span><span>Author</span><span>Created</span><span>Edited</span>
</div>
{records.map(({ example, issueIdentifier, issueTitle }) => {
const issue = example.snapshot.issue;
const projectName = projectNames.get(stringValue(issue, "projectId", "project_id") ?? "") ?? "No project";
const edited = example.updatedAt !== example.createdAt;
return (
<button key={example.id} type="button" onClick={() => navigate(decisionTrainingHref(example.id))} className="grid w-full gap-3 border-t border-border px-4 py-4 text-left transition-colors first:border-t-0 hover:bg-muted/30 md:grid-cols-6 md:items-center md:gap-4">
<span className="min-w-0"><span className="block truncate text-sm font-medium">{decisionTitle(example, issueTitle)}</span><span className="mt-1 block truncate text-xs text-muted-foreground">{issueIdentifier} · {projectName} · {example.sourceKind.replaceAll("_", " ")}</span></span>
<span className="text-sm capitalize">{outcomeLabel(example.decisionOutcome)}</span>
<span className="font-mono text-xs text-muted-foreground">{example.snapshot.cutoff.commentCount} comments · {example.snapshot.runs.length} runs · {example.snapshot.code.commitSha?.slice(0, 9) ?? "no repo"}</span>
<span className="text-sm">{authorLabel(example.createdByUserId)}</span><span className="text-xs text-muted-foreground">{formatDate(example.createdAt)}</span><span className="text-xs text-muted-foreground">{edited ? formatDate(example.updatedAt) : "—"}</span>
</button>
);
})}
</div>
</div>
);
}
function JsonPanel({ value }: { value: unknown }) {
return <pre className="overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-4 font-mono text-xs leading-relaxed">{JSON.stringify(value, null, 2)}</pre>;
}
export function TrainingThreadPanel({ example, liveComments }: { example: DecisionTrainingExample; liveComments: IssueComment[] }) {
const { included, excluded } = partitionTrainingThread(example.snapshot.comments, liveComments, example.cutoffAt);
const renderComment = (comment: SnapshotRecord, ghosted = false) => (
<div key={recordId(comment) || `${recordDate(comment)}-${stringValue(comment, "body")}`} data-excluded-from-snapshot={ghosted ? "true" : undefined} className={cn("py-4", ghosted && "opacity-50")}>
<div className="font-mono text-xs text-muted-foreground">{stringValue(comment, "authorType", "author_type") ?? "Comment"} · {recordDate(comment) ? formatDateTime(recordDate(comment)) : "Unknown time"} · {recordId(comment).slice(0, 10)}</div>
<p className="mt-2 whitespace-pre-wrap text-sm">{stringValue(comment, "body") ?? ""}</p>
{ghosted ? <p className="mt-2 text-xs font-medium text-destructive">Excluded from snapshot · after cutoff</p> : null}
</div>
);
return <div className="divide-y divide-border">{included.map((comment) => renderComment(comment))}<div data-training-cutoff className="flex items-center gap-3 py-4"><span className="h-px flex-1 bg-destructive" /><span className="font-mono text-xs font-bold text-destructive">CUTOFF · {formatDateTime(example.cutoffAt)}</span><span className="h-px flex-1 bg-destructive" /></div>{excluded.map((comment) => renderComment(comment as SnapshotRecord, true))}</div>;
}
export function TrainingInspector() {
const { id = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const [notes, setNotes] = useState("");
const [editing, setEditing] = useState(false);
const recordQuery = useQuery({ queryKey: queryKeys.decisionTraining.detail(id), queryFn: ({ signal }) => decisionTrainingApi.get(id, { signal }), enabled: Boolean(id) });
const example = recordQuery.data;
const commentsQuery = useQuery({ queryKey: ["issues", example?.issueId, "comments", "training-audit"], queryFn: () => issuesApi.listComments(example!.issueId, { order: "asc" }), enabled: Boolean(example?.issueId) });
useEffect(() => {
if (example && !editing) setNotes(example.notes);
}, [editing, example]);
useEffect(() => setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: "Training", href: decisionTrainingHref() }, { label: example ? decisionTitle(example) : "Example" }]), [example, setBreadcrumbs]);
const saveMutation = useMutation({
mutationFn: () => decisionTrainingApi.updateNotes(id, notes.trim()),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.decisionTraining.detail(id), updated);
queryClient.invalidateQueries({ queryKey: queryKeys.decisionTraining.list(updated.companyId) });
pushToast({ title: "Notes updated", tone: "success" });
setEditing(false);
},
onError: (error) => {
pushToast({
title: "Could not update notes",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
if (recordQuery.isLoading) return <p className="p-6 text-sm text-muted-foreground">Loading training example</p>;
if (recordQuery.isError) return <p className="p-6 text-sm text-destructive">Could not load training example.</p>;
if (!example) return <p className="p-6 text-sm text-destructive">Training example not found.</p>;
const issueIdentifier = stringValue(example.snapshot.issue, "identifier") ?? example.issueId.slice(0, 8);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><Button variant="ghost" size="sm" className="mb-2 -ml-2" onClick={() => navigate(decisionTrainingHref())}><ArrowLeft className="size-4" /> Training</Button><h1 className="truncate text-xl font-bold">{decisionTitle(example)}</h1><p className="mt-1 text-sm text-muted-foreground">{issueIdentifier} · {outcomeLabel(example.decisionOutcome)} · cutoff {formatDateTime(example.cutoffAt)}</p></div><Button variant="outline" onClick={() => downloadExport(example.companyId)}><Download className="size-4" /> Export JSONL</Button></header>
<div className="grid gap-8 lg:grid-cols-2">
<section><div className="mb-3 flex items-center justify-between"><div><h2 className="text-sm font-semibold">Training notes</h2><p className="mt-1 text-xs text-muted-foreground">Last edited {formatDateTime(example.updatedAt)} · edits are versioned</p></div>{!editing ? <Button variant="ghost" size="sm" onClick={() => setEditing(true)}>Edit</Button> : null}</div>{editing ? <div className="space-y-3"><Textarea value={notes} onChange={(event) => setNotes(event.target.value)} className="min-h-72" /><div className="flex justify-end gap-2"><Button variant="ghost" onClick={() => { setNotes(example.notes); setEditing(false); }}>Cancel</Button><Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending || notes.trim() === example.notes}>Save notes</Button></div></div> : <p className="whitespace-pre-wrap text-sm leading-relaxed">{example.notes || "No notes recorded."}</p>}</section>
<section className="min-w-0"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">Frozen state</h2><span className="font-mono text-xs text-muted-foreground">read-only</span></div><Tabs defaultValue="thread"><TabsList variant="line" className="w-full justify-start overflow-x-auto"><TabsTrigger value="thread">Thread</TabsTrigger><TabsTrigger value="issue">Issue</TabsTrigger><TabsTrigger value="runs">Runs</TabsTrigger><TabsTrigger value="code">Code</TabsTrigger><TabsTrigger value="decision">Decision</TabsTrigger></TabsList><TabsContent value="thread"><TrainingThreadPanel example={example} liveComments={commentsQuery.data ?? []} /></TabsContent><TabsContent value="issue"><JsonPanel value={example.snapshot.issue} /></TabsContent><TabsContent value="runs"><JsonPanel value={example.snapshot.runs} /></TabsContent><TabsContent value="code"><JsonPanel value={example.snapshot.code} /></TabsContent><TabsContent value="decision"><JsonPanel value={example.snapshot.decision} /></TabsContent></Tabs></section>
</div>
</div>
);
}

View File

@ -38,7 +38,6 @@ import {
type AttentionGroupBy,
type AttentionSortOrder,
} from "../lib/attention";
import { decisionTrainingHref } from "../lib/decisionTraining";
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
@ -47,7 +46,6 @@ import { Curtain, AgingItemRow } from "../components/DecisionShelf";
import { DecisionQueueRail } from "../components/DecisionQueueRail";
import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips";
import { DecisionResolver } from "../components/DecisionResolver";
import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
/** Curtain rows never expand; module-level so memoized rows see one identity. */
@ -100,9 +98,6 @@ export function WhatNeedsMe() {
// still follows a click, so keyboard actions target the row you just used.
const [selectionFromKeyboard, setSelectionFromKeyboard] = useState(false);
const [autoExpandDone, setAutoExpandDone] = useState(false);
// Decision-training drawer target. `null` when closed.
const [trainingItem, setTrainingItem] = useState<AttentionItem | null>(null);
// Toolbar preferences (persisted to localStorage, Inbox pattern).
const [groupBy, setGroupBy] = useState<AttentionGroupBy>(() => loadAttentionGroupBy());
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
@ -466,10 +461,6 @@ export function WhatNeedsMe() {
setSelectionFromKeyboard(false);
setExpandedId((prev) => (prev === item.id ? null : item.id));
}, []);
const handleTrain = useCallback((item: AttentionItem) => {
setTrainingItem(item);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const action = resolveAttentionQueueKeyAction({
@ -538,7 +529,6 @@ export function WhatNeedsMe() {
onGroupByChange={updateGroupBy}
sortOrder={sortOrder}
onSortOrderChange={updateSortOrder}
onOpenTraining={() => navigate(decisionTrainingHref())}
/>
</div>
@ -621,7 +611,6 @@ export function WhatNeedsMe() {
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onTrain={handleTrain}
agentMap={agentMap}
agents={agents}
showTriage
@ -712,7 +701,6 @@ export function WhatNeedsMe() {
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onTrain={handleTrain}
/>
))}
</Curtain>
@ -768,16 +756,6 @@ export function WhatNeedsMe() {
)}
</Curtain>
</div>
<DecisionTrainingDrawer
open={trainingItem !== null}
onOpenChange={(next) => {
if (!next) setTrainingItem(null);
}}
companyId={selectedCompanyId}
item={trainingItem}
currentUserId={currentUserId}
/>
</div>
);
}

View File

@ -438,7 +438,7 @@ export const AgingShelf: Story = {
/**
* Screen 2 a queue page. The queue carries the same toolbar as
* the desk (filter / group / sort / training), the date-range chips, the arrival
* the desk (filter / group / sort), the date-range chips, the arrival
* timeline groupings ("Decide now" / "New today" / "Earlier") and the aging
* shelf, above the seed-rules card (with its rewritten copy) and the per-item
* Exclude-with-reason affordance. Each source-native decision still resolves per