mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
282 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|