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:
parent
0cf64d36a5
commit
8339bcc7e7
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue