fix(ui): keep decision training under decisions (#10181)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators review decisions and turn useful outcomes into training
examples
> - The training library belongs to the Decisions surface, but its
routes and entry point appeared as a separate top-level destination
> - That mismatch made the training icon feel disconnected and left
internal links pointing at legacy `/training` URLs
> - This pull request nests the training library and inspector under
`/decisions/training`, keeps legacy URLs working through redirects, and
uses one route helper everywhere
> - The benefit is a clearer Decisions workflow without breaking
existing bookmarks or links

## Linked Issues or Issue Description

Refs: #9718
Refs: #9779

**Problem:** The Decisions page exposed training as a labeled header
action while the training library lived at the unrelated top-level
`/training` route. Links across the UI duplicated that legacy path.

**Expected behavior:** Training should be an icon action alongside the
other Decisions controls, and training library/record URLs should live
under `/decisions/training` while old URLs continue to redirect.

**Steps to reproduce:**
1. Open the Decisions page.
2. Observe the separate labeled Training button in the page heading.
3. Open a training record and note the top-level `/training/...` URL.

**Version/commit:** Current `master` before this PR.

**Deployment mode:** All UI deployment modes.

## What Changed

- Move the training library and inspector routes under
`/decisions/training`.
- Redirect legacy `/training` and `/training/:id` URLs to the new
locations.
- Add `decisionTrainingHref()` and use it for training links and
navigation.
- Place the Training icon action with the Decisions filter and sort
controls.
- Add focused tests for library and record URL generation.

## Verification

- `pnpm exec vitest run ui/src/lib/decisionTraining.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/ui build`
- `pnpm check:token-gates`

## Risks

- Low risk: the change is limited to client-side routes and navigation.
- Existing `/training` bookmarks remain supported through replacement
redirects.
- No API, database, migration, dependency, or workflow changes.

> 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 using GPT-5.4 with reasoning, repository tool use, shell
execution, and code/test verification.

## 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-07-27 19:08:36 -05:00 committed by GitHub
parent 0cf64d36a5
commit 8339bcc7e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 48 additions and 17 deletions

View File

@ -269,8 +269,10 @@ function boardRoutes() {
<Route path="artifacts" element={<Artifacts />} />
</Route>
<Route path="decisions" element={<WhatNeedsMe />} />
<Route path="training" element={<TrainingLibrary />} />
<Route path="training/:id" element={<TrainingInspector />} />
<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 />} />
@ -298,6 +300,11 @@ 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

@ -18,7 +18,7 @@ import { Link } from "@/lib/router";
import { decisionTrainingApi, type DecisionTrainingTarget } from "../api/decisionTraining";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { codeResolutionLabel, trainingTargetForItem } from "../lib/decisionTraining";
import { codeResolutionLabel, decisionTrainingHref, trainingTargetForItem } from "../lib/decisionTraining";
import { relativeTime } from "../lib/utils";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@ -385,7 +385,7 @@ function SavedState({
</AlertDialog>
<Button asChild variant="outline" size="sm">
<Link to={`/training/${record.id}`}>
<Link to={decisionTrainingHref(record.id)}>
Open full record
<ExternalLink className="size-3.5" />
</Link>
@ -456,7 +456,7 @@ function SnapshotPreview({
icon={<Clock className="size-4" />}
label="Cutoff"
value={cutoffAt ? new Date(cutoffAt).toLocaleString() : "now"}
href={exampleId ? `/training/${exampleId}` : undefined}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
<SnapshotRow
icon={<MessageSquare className="size-4" />}
@ -466,13 +466,13 @@ function SnapshotPreview({
? "None before cutoff"
: `${snapshot.cutoff.commentCount} · last ${snapshot.cutoff.lastCommentId?.slice(0, 8) ?? "—"}`
}
href={exampleId ? `/training/${exampleId}` : undefined}
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 ? `/training/${exampleId}` : undefined}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
<SnapshotRow
icon={<GitCommitHorizontal className="size-4" />}
@ -482,7 +482,7 @@ function SnapshotPreview({
? `${snapshot.code.commitSha.slice(0, 10)} · ${codeResolutionLabel(snapshot.code.resolution)}`
: codeResolutionLabel(snapshot.code.resolution)
}
href={exampleId ? `/training/${exampleId}` : undefined}
href={exampleId ? decisionTrainingHref(exampleId) : undefined}
/>
</div>
)}

View File

@ -0,0 +1,12 @@
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,6 +1,10 @@
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.

View File

@ -14,6 +14,7 @@ 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";
@ -143,7 +144,7 @@ export function TrainingLibrary() {
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(`/training/${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">
<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>
@ -186,7 +187,7 @@ export function TrainingInspector() {
useEffect(() => {
if (example && !editing) setNotes(example.notes);
}, [editing, example]);
useEffect(() => setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: "Training", href: "/training" }, { label: example ? decisionTitle(example) : "Example" }]), [example, setBreadcrumbs]);
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) => {
@ -210,7 +211,7 @@ export function TrainingInspector() {
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("/training")}><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>
<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>

View File

@ -36,6 +36,7 @@ import {
type AttentionGroupBy,
type AttentionSortOrder,
} from "../lib/attention";
import { decisionTrainingHref } from "../lib/decisionTraining";
import { cn } from "../lib/utils";
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
import { PageSkeleton } from "../components/PageSkeleton";
@ -423,12 +424,7 @@ export function WhatNeedsMe() {
return (
<div ref={rootRef} className="max-w-3xl space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-4">
<h1 className="text-xl font-bold">Decisions</h1>
<Button variant="outline" size="sm" onClick={() => navigate("/training")}>
<GraduationCap className="size-4" /> Training
</Button>
</div>
<h1 className="text-xl font-bold">Decisions</h1>
<div className="flex items-center gap-2">
{visibleCount > 0 && (
<span className="text-sm text-muted-foreground">
@ -490,6 +486,17 @@ export function WhatNeedsMe() {
</div>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Training"
aria-label="Training"
onClick={() => navigate(decisionTrainingHref())}
>
<GraduationCap className="h-3.5 w-3.5" />
</Button>
{/* Sort */}
<Popover>
<PopoverTrigger asChild>