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

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

## Linked Issues or Issue Description

**What existing behavior does this improve?**

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

**Subsystem affected**

`ui/` — React and Vite board UI.

**Current behavior**

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

**Proposed behavior**

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

**Reason and benefit**

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

**Breaking changes**

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

282 lines
8.9 KiB
TypeScript

import { type ReactNode } from "react";
import { ArrowUpDown, Check, Layers, ListFilter } from "lucide-react";
import {
ATTENTION_GROUP_BY_OPTIONS,
ATTENTION_SORT_OPTIONS,
buildAttentionFilterOptions,
countActiveAttentionFilters,
defaultAttentionFilterState,
NO_GROUP_SENTINEL,
sourceMeta,
type AttentionFilterState,
type AttentionGroupBy,
type AttentionSortOrder,
} from "../lib/attention";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
const SEVERITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
interface DecisionsToolbarProps {
/** Number of decisions currently shown, for the count pill. */
visibleCount: number;
filterOptions: ReturnType<typeof buildAttentionFilterOptions>;
filters: AttentionFilterState;
onFiltersChange: (next: AttentionFilterState) => void;
groupBy: AttentionGroupBy;
onGroupByChange: (next: AttentionGroupBy) => void;
sortOrder: AttentionSortOrder;
onSortOrderChange: (next: AttentionSortOrder) => void;
}
/**
* 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.
*/
export function DecisionsToolbar({
visibleCount,
filterOptions,
filters,
onFiltersChange,
groupBy,
onGroupByChange,
sortOrder,
onSortOrderChange,
}: DecisionsToolbarProps) {
const activeFilterCount = countActiveAttentionFilters(filters);
return (
<div className="flex items-center gap-2">
{visibleCount > 0 && (
<span className="text-sm text-muted-foreground">
{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
</span>
)}
{/* Filter */}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", activeFilterCount > 0 && "bg-accent")}
title="Filter"
aria-label="Filter"
>
<ListFilter className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-0">
<FilterMenu options={filterOptions} filters={filters} onChange={onFiltersChange} />
</PopoverContent>
</Popover>
{/* Group by */}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
aria-label="Group"
>
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2">
<div className="space-y-0.5">
{ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => onGroupByChange(value)}
>
<span>{label}</span>
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Sort */}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Sort"
aria-label="Sort"
>
<ArrowUpDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-2">
<div className="space-y-0.5">
{ATTENTION_SORT_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
sortOrder === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => onSortOrderChange(value)}
>
<span>{label}</span>
{sortOrder === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
function FilterMenu({
options,
filters,
onChange,
}: {
options: ReturnType<typeof buildAttentionFilterOptions>;
filters: AttentionFilterState;
onChange: (next: AttentionFilterState) => void;
}) {
const toggle = (key: keyof AttentionFilterState, value: string) => {
const list = filters[key] as string[];
const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
onChange({ ...filters, [key]: nextList });
};
const hasActive = countActiveAttentionFilters(filters) > 0;
return (
<div className="max-h-(--sz-70vh) overflow-y-auto">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
{hasActive && (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => onChange(defaultAttentionFilterState)}
>
Clear
</button>
)}
</div>
{options.sourceKinds.length > 1 && (
<FilterSection title="Type">
{options.sourceKinds.map((kind) => (
<FilterRow
key={kind}
label={sourceMeta(kind).label}
checked={filters.sourceKinds.includes(kind)}
onToggle={() => toggle("sourceKinds", kind)}
/>
))}
</FilterSection>
)}
{options.severities.length > 1 && (
<FilterSection title="Severity">
{options.severities.map((severity) => (
<FilterRow
key={severity}
label={SEVERITY_LABELS[severity] ?? severity}
checked={filters.severities.includes(severity)}
onToggle={() => toggle("severities", severity)}
/>
))}
</FilterSection>
)}
{(options.projects.length > 0 || options.hasNoProject) && (
<FilterSection title="Project">
{options.projects.map((project) => (
<FilterRow
key={project.id}
label={project.name}
checked={filters.projectIds.includes(project.id)}
onToggle={() => toggle("projectIds", project.id)}
/>
))}
{options.hasNoProject && (
<FilterRow
label="No project"
checked={filters.projectIds.includes(NO_GROUP_SENTINEL)}
onToggle={() => toggle("projectIds", NO_GROUP_SENTINEL)}
/>
)}
</FilterSection>
)}
{(options.workspaces.length > 0 || options.hasNoWorkspace) && (
<FilterSection title="Workspace">
{options.workspaces.map((workspace) => (
<FilterRow
key={workspace.id}
label={workspace.name}
checked={filters.workspaceIds.includes(workspace.id)}
onToggle={() => toggle("workspaceIds", workspace.id)}
/>
))}
{options.hasNoWorkspace && (
<FilterRow
label="No workspace"
checked={filters.workspaceIds.includes(NO_GROUP_SENTINEL)}
onToggle={() => toggle("workspaceIds", NO_GROUP_SENTINEL)}
/>
)}
</FilterSection>
)}
</div>
);
}
function FilterSection({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="border-t border-border/60 px-2 py-1.5">
<p className="px-1 pb-1 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<div className="space-y-0.5">{children}</div>
</div>
);
}
function FilterRow({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-left text-sm hover:bg-accent/50"
onClick={onToggle}
>
<Checkbox checked={checked} className="pointer-events-none" tabIndex={-1} />
<span className="truncate">{label}</span>
</button>
);
}