Skip to content

Commit 28bb8a1

Browse files
committed
app: skills review fixes — selection token, toggle race guard, save gate
Selection uses border-primary (brand stays reserved for pipeline identity); review-pipeline toggles are disabled per-skill while a command is in flight so a stale response can't clobber the final click; handleSave re-asserts editability locally; failure rollback re-reads before surfacing the error so the message survives. Store tests pin all three behaviors.
1 parent e83a569 commit 28bb8a1

3 files changed

Lines changed: 118 additions & 7 deletions

File tree

‎app/src/components/SkillsView.tsx‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ interface SkillRowProps {
9191
readonly onSelect: () => void;
9292
readonly inline: "toggle" | "badge";
9393
readonly onTogglePipeline: (enabled: boolean) => void;
94+
readonly togglePending: boolean;
9495
}
9596

9697
function SkillRow({
@@ -99,13 +100,14 @@ function SkillRow({
99100
onSelect,
100101
inline,
101102
onTogglePipeline,
103+
togglePending,
102104
}: SkillRowProps) {
103105
return (
104106
<div
105107
className={cn(
106108
"flex items-center gap-2 border-l-2 pr-2 transition-colors",
107109
selected
108-
? "border-brand bg-muted"
110+
? "border-primary bg-muted"
109111
: "border-transparent hover:bg-muted/50",
110112
)}
111113
>
@@ -128,6 +130,7 @@ function SkillRow({
128130
size="sm"
129131
checked={skill.in_review_pipeline}
130132
onCheckedChange={onTogglePipeline}
133+
disabled={togglePending}
131134
aria-label={`Toggle ${skill.name} in review pipeline`}
132135
/>
133136
) : (
@@ -160,6 +163,7 @@ export function SkillsView() {
160163
const deleteSkill = useAppStore((s) => s.deleteSkill);
161164
const syncSkills = useAppStore((s) => s.syncSkills);
162165
const setSkillReviewPipeline = useAppStore((s) => s.setSkillReviewPipeline);
166+
const pendingSkillToggles = useAppStore((s) => s.pendingSkillToggles);
163167

164168
const [selection, setSelection] = useState<Selection>({ kind: "none" });
165169
const [search, setSearch] = useState("");
@@ -232,7 +236,11 @@ export function SkillsView() {
232236
setFeedback({ kind: "error", message: "A skill name is required." });
233237
return;
234238
}
235-
} else if (selection.kind === "skill" && selectedSkill !== null) {
239+
} else if (
240+
selection.kind === "skill" &&
241+
selectedSkill !== null &&
242+
isEditable(selectedSkill.source)
243+
) {
236244
name = selectedSkill.name;
237245
} else {
238246
return;
@@ -418,6 +426,9 @@ export function SkillsView() {
418426
onTogglePipeline={(enabled) => {
419427
handleToggle(skill, enabled);
420428
}}
429+
togglePending={pendingSkillToggles.includes(
430+
skill.name,
431+
)}
421432
/>
422433
))}
423434
</div>
@@ -474,6 +485,7 @@ export function SkillsView() {
474485
onToggle={(enabled) => {
475486
handleToggle(selectedSkill, enabled);
476487
}}
488+
togglePending={pendingSkillToggles.includes(selectedSkill.name)}
477489
onEdit={() => {
478490
handleEdit(selectedSkill);
479491
}}
@@ -530,9 +542,10 @@ export function SkillsView() {
530542
interface UseInReviewCardProps {
531543
readonly enabled: boolean;
532544
readonly onToggle: (enabled: boolean) => void;
545+
readonly pending: boolean;
533546
}
534547

535-
function UseInReviewCard({ enabled, onToggle }: UseInReviewCardProps) {
548+
function UseInReviewCard({ enabled, onToggle, pending }: UseInReviewCardProps) {
536549
return (
537550
<div className="rounded-lg border border-border bg-card p-4">
538551
<div className="flex items-start justify-between gap-4">
@@ -551,6 +564,7 @@ function UseInReviewCard({ enabled, onToggle }: UseInReviewCardProps) {
551564
<Switch
552565
checked={enabled}
553566
onCheckedChange={onToggle}
567+
disabled={pending}
554568
aria-label="Use this skill in the review pipeline"
555569
/>
556570
</div>
@@ -562,6 +576,7 @@ function UseInReviewCard({ enabled, onToggle }: UseInReviewCardProps) {
562576
interface SkillDetailProps {
563577
readonly skill: Skill;
564578
readonly onToggle: (enabled: boolean) => void;
579+
readonly togglePending: boolean;
565580
readonly onEdit: () => void;
566581
readonly onDelete: () => void;
567582
readonly deleting: boolean;
@@ -570,6 +585,7 @@ interface SkillDetailProps {
570585
function SkillDetail({
571586
skill,
572587
onToggle,
588+
togglePending,
573589
onEdit,
574590
onDelete,
575591
deleting,
@@ -601,7 +617,11 @@ function SkillDetail({
601617
</div>
602618
)}
603619

604-
<UseInReviewCard enabled={skill.in_review_pipeline} onToggle={onToggle} />
620+
<UseInReviewCard
621+
enabled={skill.in_review_pipeline}
622+
onToggle={onToggle}
623+
pending={togglePending}
624+
/>
605625

606626
{skill.description !== "" && (
607627
<p className="text-sm text-muted-foreground">{skill.description}</p>

‎app/src/store.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
22
import type { PendingPermission } from "./store";
33
import type { Review } from "./bindings/Review";
4+
import type { Skill } from "./bindings/Skill";
45
import type { CiCheck } from "./bindings/CiCheck";
56
import type { SubmitReviewResult } from "./bindings/SubmitReviewResult";
67
import type { EvidenceSummary } from "./bindings/EvidenceSummary";
@@ -843,3 +844,74 @@ describe("applyRestackProgress", () => {
843844
expect(progress["root-b"]?.status).toBe("done");
844845
});
845846
});
847+
848+
describe("setSkillReviewPipeline", () => {
849+
const storeSkill = {
850+
name: "house-style",
851+
description: "d",
852+
tags: [],
853+
body: "b",
854+
path: "/tmp/store/house-style",
855+
source: { kind: "CockpitStore" },
856+
in_review_pipeline: false,
857+
} satisfies Skill;
858+
859+
it("flips optimistically, then reconciles with the returned list", async () => {
860+
useAppStore.setState({ skills: [storeSkill] });
861+
const returned = [{ ...storeSkill, in_review_pipeline: true }];
862+
let observedDuringFlight: boolean | undefined;
863+
mockInvoke("set_skill_review_pipeline", () => {
864+
observedDuringFlight =
865+
useAppStore.getState().skills[0]?.in_review_pipeline;
866+
return returned;
867+
});
868+
869+
await useAppStore
870+
.getState()
871+
.setSkillReviewPipeline("house-style", true);
872+
873+
expect(observedDuringFlight).toBe(true);
874+
expect(useAppStore.getState().skills).toEqual(returned);
875+
expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
876+
});
877+
878+
it("rolls back via a re-read when the command fails", async () => {
879+
useAppStore.setState({ skills: [storeSkill] });
880+
mockInvokeReject("set_skill_review_pipeline", "config write failed");
881+
mockInvoke("list_skills", () => [storeSkill]);
882+
883+
await useAppStore
884+
.getState()
885+
.setSkillReviewPipeline("house-style", true);
886+
887+
expect(useAppStore.getState().skills[0]?.in_review_pipeline).toBe(false);
888+
expect(useAppStore.getState().error).toContain("config write failed");
889+
expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
890+
});
891+
892+
it("ignores a re-entrant toggle while one is in flight", async () => {
893+
useAppStore.setState({ skills: [storeSkill] });
894+
let release: (() => void) | undefined;
895+
const gate = new Promise<void>((resolve) => {
896+
release = resolve;
897+
});
898+
mockInvoke("set_skill_review_pipeline", async () => {
899+
await gate;
900+
return [{ ...storeSkill, in_review_pipeline: true }];
901+
});
902+
903+
const first = useAppStore
904+
.getState()
905+
.setSkillReviewPipeline("house-style", true);
906+
// Second click while the first is pending must be dropped, not queued.
907+
const second = useAppStore
908+
.getState()
909+
.setSkillReviewPipeline("house-style", false);
910+
release?.();
911+
await Promise.all([first, second]);
912+
913+
expect(callsFor("set_skill_review_pipeline")).toHaveLength(1);
914+
expect(useAppStore.getState().skills[0]?.in_review_pipeline).toBe(true);
915+
expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
916+
});
917+
});

‎app/src/store.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,21 @@ interface AppStore {
415415
/** Sync skills from the configured GitHub source. */
416416
syncSkills: () => Promise<SyncReport | null>;
417417

418+
/**
419+
* Skill names with a review-pipeline toggle currently in flight. The UI
420+
* disables those switches so a rapid double-toggle can never race two
421+
* commands whose stale response would clobber the user's final click.
422+
*/
423+
readonly pendingSkillToggles: readonly string[];
424+
418425
/**
419426
* Toggle a skill's membership in the review pipeline (explicit user action).
420427
*
421428
* Flips `in_review_pipeline` optimistically for instant feedback, then
422429
* reconciles `skills` with the refreshed list the command returns. On failure
423430
* it surfaces the error and re-reads the authoritative list so the optimistic
424-
* flip never sticks.
431+
* flip never sticks. Re-entrant toggles for a skill already in flight are
432+
* ignored (the switch is disabled while pending).
425433
*/
426434
setSkillReviewPipeline: (name: string, enabled: boolean) => Promise<void>;
427435

@@ -535,6 +543,7 @@ export const useAppStore = create<AppStore>((set, get) => ({
535543
kickoffResult: null,
536544
skills: [],
537545
skillsLoading: false,
546+
pendingSkillToggles: [],
538547
authoredPrs: [],
539548
reviewRequests: [],
540549
prFetchLoading: false,
@@ -1201,8 +1210,10 @@ export const useAppStore = create<AppStore>((set, get) => ({
12011210
},
12021211

12031212
setSkillReviewPipeline: async (name: string, enabled: boolean) => {
1213+
if (get().pendingSkillToggles.includes(name)) return;
12041214
// Optimistic flip so the toggle feels instant; reconciled below.
12051215
set({
1216+
pendingSkillToggles: [...get().pendingSkillToggles, name],
12061217
skills: get().skills.map((s) =>
12071218
s.name === name ? { ...s, in_review_pipeline: enabled } : s,
12081219
),
@@ -1215,9 +1226,17 @@ export const useAppStore = create<AppStore>((set, get) => ({
12151226
});
12161227
set({ skills });
12171228
} catch (e: unknown) {
1218-
set({ error: String(e) });
1219-
// Re-read the authoritative list so the optimistic flip does not stick.
1229+
// Re-read the authoritative list so the optimistic flip does not stick,
1230+
// THEN surface the error — listSkills clears `error` on entry, so the
1231+
// opposite order would wipe the message before anyone saw it.
12201232
await get().listSkills();
1233+
set({ error: String(e) });
1234+
} finally {
1235+
set({
1236+
pendingSkillToggles: get().pendingSkillToggles.filter(
1237+
(n) => n !== name,
1238+
),
1239+
});
12211240
}
12221241
},
12231242

0 commit comments

Comments
 (0)