Skip to content

Commit 6fe5518

Browse files
committed
UX#1: FeatureInjectionBar — dedupe applied list into chips with ×N count
Screenshot evidence: the bar at the top of the canvas showed "mtp_weighted, mtp_weighted, mtp_weighted, mtp_weighted" as a comma-joined string whenever the user clicked Apply for the same injection more than once. Fix: - Group applied[] by name → render ONE chip per name with a "×N" count badge when N>1. - Each chip gets its own × remove button. - New optional onRemove(injection) prop; App.tsx wires it to pop the last matching rewriter from spec.rewriters via dispatch. Tests (3 new in FeatureInjectionBar.test.tsx, 7 total): - 4 Apply clicks → 1 chip + count "×4", no comma-joined leak - × remove fires onRemove with the popped injection, count drops - single-instance chip has no count badge
1 parent cd1fbd1 commit 6fe5518

3 files changed

Lines changed: 171 additions & 12 deletions

File tree

vbgui/src/App.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,31 @@ export function App(): JSX.Element {
779779
}
780780
}, [nodes, featureInjections.length]);
781781

782+
const handleFeatureInjectionRemove = useCallback((
783+
injection: AppliedInjection,
784+
) => {
785+
setFeatureInjections((prev) => {
786+
const next = [...prev];
787+
for (let i = next.length - 1; i >= 0; i--) {
788+
if (next[i].name === injection.name) {
789+
next.splice(i, 1);
790+
break;
791+
}
792+
}
793+
return next;
794+
});
795+
if (injection.paper_ref.startsWith("rewriter:")) {
796+
const name = injection.paper_ref.slice("rewriter:".length);
797+
let lastIdx = -1;
798+
for (let i = spec.rewriters.length - 1; i >= 0; i--) {
799+
if (spec.rewriters[i].name === name) { lastIdx = i; break; }
800+
}
801+
if (lastIdx >= 0) {
802+
dispatch({ type: "rewriters.remove", index: lastIdx });
803+
}
804+
}
805+
}, [spec.rewriters]);
806+
782807
// V8-R02: apply a scaled-down preset from the GalleryScaleDownSlider.
783808
// The slider already ran architectures.scale_down and hands us back
784809
// the wire-form specs + chosen (hidden_size, num_layers). We swap the
@@ -1614,6 +1639,7 @@ export function App(): JSX.Element {
16141639
rpc={rpc}
16151640
applied={featureInjections}
16161641
onApply={handleFeatureInjectionApply}
1642+
onRemove={handleFeatureInjectionRemove}
16171643
/>
16181644
<DimEnvEditor value={dimEnv} onApply={setDimEnv} />
16191645
<TrainOptionsPanel

vbgui/src/components/FeatureInjectionBar.tsx

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { useEffect, useState } from "react";
1616
import type { RpcClient } from "@/lib/rpc";
17+
import { T } from "@/theme";
1718

1819
interface CatalogOption {
1920
name: string;
@@ -32,10 +33,14 @@ export interface FeatureInjectionBarProps {
3233
/** Optional list of already-applied injections; the bar uses this
3334
* to populate the applied-list display when the parent persists. */
3435
applied?: AppliedInjection[];
36+
/** Optional remove callback. Called with the last-applied injection
37+
* matching the chip the user clicked × on. Parent is responsible
38+
* for popping the matching rewriter / brick. */
39+
onRemove?: (injection: AppliedInjection) => void;
3540
}
3641

3742
export function FeatureInjectionBar({
38-
rpc, onApply, applied = [],
43+
rpc, onApply, applied = [], onRemove,
3944
}: FeatureInjectionBarProps): JSX.Element {
4045
const [options, setOptions] = useState<CatalogOption[]>([]);
4146
const [selected, setSelected] = useState<string>("");
@@ -74,19 +79,50 @@ export function FeatureInjectionBar({
7479
onApply(injection);
7580
}
7681

82+
function removeOne(name: string) {
83+
let popped: AppliedInjection | null = null;
84+
for (let i = local.length - 1; i >= 0; i--) {
85+
if (local[i].name === name) { popped = local[i]; break; }
86+
}
87+
if (!popped) return;
88+
setLocal((prev) => {
89+
const next = [...prev];
90+
for (let i = next.length - 1; i >= 0; i--) {
91+
if (next[i].name === name) { next.splice(i, 1); break; }
92+
}
93+
return next;
94+
});
95+
if (onRemove) onRemove(popped);
96+
}
97+
98+
const counts = new Map<string, { count: number; paper_ref: string }>();
99+
for (const a of local) {
100+
const prev = counts.get(a.name);
101+
counts.set(a.name, {
102+
count: (prev?.count ?? 0) + 1,
103+
paper_ref: a.paper_ref,
104+
});
105+
}
106+
const chipEntries = Array.from(counts.entries());
107+
77108
return (
78109
<div data-testid="feature-injection-bar"
79110
style={{ display: "flex", alignItems: "center", gap: 6,
80-
padding: 6, background: "#fefce8",
81-
border: "1px solid #fde047", borderRadius: 4,
82-
fontSize: 12 }}>
83-
<strong style={{ color: "#0f172a", marginRight: 2 }}>Inject</strong>
84-
<label style={{ display: "flex", alignItems: "center", gap: 4, color: "#0f172a" }}>
111+
padding: "4px 8px", background: T.surface,
112+
borderBottom: `1px solid ${T.border}`,
113+
fontSize: 12, fontFamily: T.font, color: T.text }}>
114+
<strong style={{ color: T.accent, marginRight: 2 }}>Inject</strong>
115+
<label style={{ display: "flex", alignItems: "center", gap: 4, color: T.textSecondary }}>
85116
<select
86117
data-testid="feature-injection-dropdown"
87118
value={selected}
88119
onChange={(e) => setSelected(e.target.value)}
89120
disabled={options.length === 0}
121+
style={{
122+
color: T.text,
123+
background: T.surface3,
124+
border: `1px solid ${T.border}`,
125+
}}
90126
>
91127
{options.length === 0 && <option value=""></option>}
92128
{options.map((o) => (
@@ -96,23 +132,54 @@ export function FeatureInjectionBar({
96132
</label>
97133
{selected && (
98134
<span data-testid="feature-injection-summary"
99-
style={{ color: "#334155", fontWeight: 500 }}>
135+
style={{ color: T.textSecondary, fontWeight: 500 }}>
100136
{options.find((o) => o.name === selected)?.summary ?? ""}
101137
</span>
102138
)}
103139
<button
104140
data-testid="feature-injection-apply"
105141
onClick={apply}
106142
disabled={!selected}
107-
style={{ padding: "2px 8px" }}>
143+
style={{
144+
padding: "2px 8px",
145+
background: T.surface3,
146+
color: T.text,
147+
border: `1px solid ${T.border}`,
148+
}}>
108149
Apply
109150
</button>
110151
{err && <span data-testid="feature-injection-error"
111-
style={{ color: "#b91c1c" }}>{err}</span>}
152+
style={{ color: T.danger }}>{err}</span>}
112153
<span data-testid="feature-injection-applied-list"
113-
style={{ color: "#374151", marginLeft: "auto" }}>
114-
{local.length === 0 ? "—" :
115-
local.map((a) => a.name).join(", ")}
154+
style={{ display: "inline-flex", flexWrap: "wrap", gap: 4,
155+
marginLeft: "auto", alignItems: "center" }}>
156+
{chipEntries.length === 0 ? (
157+
<span style={{ color: T.textMuted }}></span>
158+
) : chipEntries.map(([name, { count }]) => (
159+
<span key={name}
160+
data-testid={`feature-injection-chip-${name}`}
161+
style={{ display: "inline-flex", alignItems: "center",
162+
gap: 4, padding: "1px 4px 1px 6px",
163+
background: T.surface3,
164+
border: `1px solid ${T.border}`,
165+
borderRadius: 10, fontSize: 11, color: T.text }}>
166+
<span>{name}</span>
167+
{count > 1 && (
168+
<span data-testid={`feature-injection-chip-${name}-count`}
169+
style={{ color: T.textSecondary, fontWeight: 600 }}>
170+
×{count}
171+
</span>
172+
)}
173+
<button data-testid={`feature-injection-chip-${name}-remove`}
174+
onClick={() => removeOne(name)}
175+
title={count > 1 ? `remove one ${name}` : `remove ${name}`}
176+
style={{ background: "transparent", border: "none",
177+
color: T.textSecondary, cursor: "pointer",
178+
fontSize: 12, lineHeight: 1, padding: "0 2px" }}>
179+
×
180+
</button>
181+
</span>
182+
))}
116183
</span>
117184
</div>
118185
);

vbgui/tests/FeatureInjectionBar.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,72 @@ describe("V8-R08 FeatureInjectionBar", () => {
107107
});
108108
});
109109

110+
it("applying the same option N times renders ONE chip with ×N count " +
111+
"(UX#1: no more comma-joined mtp_weighted,mtp_weighted,...)", async () => {
112+
const { rpc } = makeFakeRpc({ "catalog.list_options": CATALOG_OK });
113+
render(<FeatureInjectionBar rpc={rpc} onApply={() => {}} />);
114+
await waitFor(() => {
115+
expect(screen.getByTestId("feature-injection-dropdown")).toBeDefined();
116+
});
117+
const applyBtn = screen.getByTestId("feature-injection-apply");
118+
fireEvent.click(applyBtn);
119+
fireEvent.click(applyBtn);
120+
fireEvent.click(applyBtn);
121+
fireEvent.click(applyBtn);
122+
123+
// ONE chip, not four.
124+
expect(screen.getAllByTestId("feature-injection-chip-mtp_weighted"))
125+
.toHaveLength(1);
126+
// With ×4 count badge.
127+
expect(screen.getByTestId("feature-injection-chip-mtp_weighted-count")
128+
.textContent).toBe("×4");
129+
// Applied list should NOT contain the literal comma-joined string
130+
// "mtp_weighted, mtp_weighted, mtp_weighted, mtp_weighted".
131+
const list = screen.getByTestId("feature-injection-applied-list");
132+
expect(list.textContent ?? "").not.toContain(
133+
"mtp_weighted, mtp_weighted");
134+
});
135+
136+
it("chip × button calls onRemove and pops one instance", async () => {
137+
const { rpc } = makeFakeRpc({ "catalog.list_options": CATALOG_OK });
138+
const onRemove = vi.fn();
139+
render(<FeatureInjectionBar rpc={rpc} onApply={() => {}}
140+
onRemove={onRemove} />);
141+
await waitFor(() => {
142+
expect(screen.getByTestId("feature-injection-dropdown")).toBeDefined();
143+
});
144+
const applyBtn = screen.getByTestId("feature-injection-apply");
145+
fireEvent.click(applyBtn);
146+
fireEvent.click(applyBtn);
147+
fireEvent.click(applyBtn);
148+
expect(screen.getByTestId("feature-injection-chip-mtp_weighted-count")
149+
.textContent).toBe("×3");
150+
151+
fireEvent.click(screen.getByTestId(
152+
"feature-injection-chip-mtp_weighted-remove"));
153+
expect(onRemove).toHaveBeenCalledTimes(1);
154+
expect(onRemove.mock.calls[0][0]).toEqual({
155+
name: "mtp_weighted",
156+
paper_ref: "rewriter:MTPRewriter",
157+
});
158+
// Count drops to ×2.
159+
expect(screen.getByTestId("feature-injection-chip-mtp_weighted-count")
160+
.textContent).toBe("×2");
161+
});
162+
163+
it("single-instance chip renders without a count badge", async () => {
164+
const { rpc } = makeFakeRpc({ "catalog.list_options": CATALOG_OK });
165+
render(<FeatureInjectionBar rpc={rpc} onApply={() => {}} />);
166+
await waitFor(() => {
167+
expect(screen.getByTestId("feature-injection-dropdown")).toBeDefined();
168+
});
169+
fireEvent.click(screen.getByTestId("feature-injection-apply"));
170+
expect(screen.getByTestId("feature-injection-chip-mtp_weighted"))
171+
.toBeDefined();
172+
expect(screen.queryByTestId(
173+
"feature-injection-chip-mtp_weighted-count")).toBeNull();
174+
});
175+
110176
it("renders error banner on RPC failure", async () => {
111177
const { rpc } = makeFakeRpc({
112178
"catalog.list_options": new Error("catalog down"),

0 commit comments

Comments
 (0)