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:
parent
45dfb183b6
commit
145d86911b
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue