fix: address Copilot review round 13 on #2251

- SkillsScreen: put the SESSION in the invalidation key. This screen stays
  mounted across a disconnect, so content alone cannot tell server A's entry
  from an identical-looking one on server B — A's in-flight verification
  could land afterwards and report `verified` for a file never read from B,
  and a retained batch entry could leave B's Verify all disabled.
  `useInspectorStores` now exposes a `sessionNonce`, bumped on both create
  and destroy so it never repeats across a reconnect, and App keys on
  `${activeServerId}:${sessionNonce}` — the server id alone would repeat,
  which is one of the crossings this exists to prevent.
- SkillsScreen: match the selection by `skillUriIdentity`, in both the
  lookup and the NavLink active check. A refresh that canonicalizes
  `skill://demo/%53KILL.md` names the same skill, and the detail pane was
  emptying out because the server changed its spelling — the last raw-string
  URI comparison left after round 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 01:40:28 -04:00
commit 4d7dd180d9
9 changed files with 135 additions and 7 deletions

View file

@ -459,6 +459,7 @@ function App() {
tasks, tasks,
refreshTasks, refreshTasks,
clearCompletedTasks, clearCompletedTasks,
sessionNonce,
skills, skills,
skillsPageCount, skillsPageCount,
skillsLoadError, skillsLoadError,
@ -1826,6 +1827,10 @@ function App() {
}; };
const skillsPanelProps: SkillsPanelProps = { const skillsPanelProps: SkillsPanelProps = {
// Server id AND per-connect nonce: the id alone would repeat on a
// reconnect to the same server, which is one of the crossings this key
// exists to prevent.
skillsSessionKey: `${activeServerId ?? ""}:${sessionNonce}`,
skills, skills,
skillsPageCount, skillsPageCount,
skillsLoadError, skillsLoadError,

View file

@ -101,6 +101,7 @@ const readFixtureFile = vi.fn(async (uri: string) => {
}); });
const baseProps: SkillsScreenProps = { const baseProps: SkillsScreenProps = {
sessionKey: "session-1",
skills: ALL_SKILLS, skills: ALL_SKILLS,
pageCount: 2, pageCount: 2,
ui: EMPTY_SKILLS_UI, ui: EMPTY_SKILLS_UI,
@ -718,6 +719,67 @@ describe("SkillsScreen", () => {
expect(verifyAll()).toBeDisabled(); expect(verifyAll()).toBeDisabled();
}); });
it("discards a verification that lands after the session changed", async () => {
// This screen stays mounted across a disconnect, so content alone does not
// tell server A's entry from an identical-looking one on server B. Without
// the session in the key, A's in-flight read would land and report
// `verified` for a file that was never read from B.
const user = userEvent.setup();
let release: ((value: { text: string }) => void) | undefined;
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
release = resolve;
}),
);
const { rerender } = renderWithMantine(
<SkillsScreen
{...baseProps}
sessionKey="server-a:1"
onReadSkillFile={onReadSkillFile}
skills={[CLEAN_SKILL]}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>,
);
await user.click(screen.getByRole("button", { name: /Verify all/ }));
// Same entry, different session.
rerender(
<SkillsScreen
{...baseProps}
sessionKey="server-b:2"
onReadSkillFile={onReadSkillFile}
skills={[CLEAN_SKILL]}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>,
);
release?.({ text: SELF_TEXT });
expect(screen.queryByText("verified")).not.toBeInTheDocument();
// ...and the batch guard did not carry over either.
expect(
screen.getByRole("button", { name: /Verify all/ }),
).not.toBeDisabled();
});
it("keeps the selection when a refresh canonicalizes the skill's URI", async () => {
// The selection is stored as the URI the list gave us, so a server that
// re-spells it must not empty the detail pane for the same skill.
renderWithMantine(
<SkillsScreen
{...baseProps}
skills={[CLEAN_SKILL]}
ui={{
...EMPTY_SKILLS_UI,
selectedSkillUri: "skill://data-analysis/%53KILL.md",
}}
/>,
);
expect(screen.getByTestId("skill-detail")).toBeInTheDocument();
expect(
screen.queryByText("Select a skill to view details"),
).not.toBeInTheDocument();
});
it("shows the SKILL.md preview on demand", async () => { it("shows the SKILL.md preview on demand", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />); renderWithMantine(<ControlledSkillsScreen />);

View file

@ -108,6 +108,14 @@ interface FetchedEntryState {
} }
export interface SkillsScreenProps { export interface SkillsScreenProps {
/**
* Identity of the connected session. Part of the invalidation key below, so
* a verification still in flight when the user switches servers cannot land
* afterwards and report a verdict for an identical-looking entry on the new
* one — this screen stays mounted across a disconnect, so content alone does
* not distinguish the two.
*/
sessionKey: string;
skills: SkillEntry[]; skills: SkillEntry[];
/** Pages the last `skills/list` walk took; shown so pagination is visible. */ /** Pages the last `skills/list` walk took; shown so pagination is visible. */
pageCount: number; pageCount: number;
@ -320,6 +328,7 @@ function shortDigest(digest: string | undefined): string {
* the Inspector fetches only what the user asks it to. * the Inspector fetches only what the user asks it to.
*/ */
export function SkillsScreen({ export function SkillsScreen({
sessionKey,
skills, skills,
pageCount, pageCount,
loadError, loadError,
@ -378,10 +387,15 @@ export function SkillsScreen({
); );
}, [skills, search]); }, [skills, search]);
const selected = useMemo( // Matched by IDENTITY, like every other URI comparison here: a refresh that
() => skills.find((skill) => skill.uri === selectedSkillUri), // canonicalizes `skill://demo/%53KILL.md` to `skill://demo/SKILL.md` names
[skills, selectedSkillUri], // the same skill, and the detail pane must not empty out because the server
); // changed its spelling.
const selected = useMemo(() => {
if (selectedSkillUri === undefined) return undefined;
const wanted = skillUriIdentity(selectedSkillUri);
return skills.find((skill) => skillUriIdentity(skill.uri) === wanted);
}, [skills, selectedSkillUri]);
const issues = useMemo( const issues = useMemo(
() => (selected ? checkSkillConformance(selected) : []), () => (selected ? checkSkillConformance(selected) : []),
@ -406,8 +420,9 @@ export function SkillsScreen({
// not. A primitive string, because `useValueChange` compares with `Object.is` // not. A primitive string, because `useValueChange` compares with `Object.is`
// and a fresh object every render would loop. // and a fresh object every render would loop.
const manifestKey = useMemo( const manifestKey = useMemo(
() => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")), () =>
[selected, selectedSkillUri], `${sessionKey}\n${selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")}`,
[selected, selectedSkillUri, sessionKey],
); );
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a // Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
@ -623,7 +638,11 @@ export function SkillsScreen({
return ( return (
<NavLink <NavLink
key={skill.uri} key={skill.uri}
active={skill.uri === selectedSkillUri} active={
selectedSkillUri !== undefined &&
skillUriIdentity(skill.uri) ===
skillUriIdentity(selectedSkillUri)
}
label={skillDisplayName(skill)} label={skillDisplayName(skill)}
description={skill.uri} description={skill.uri}
onClick={() => onClick={() =>

View file

@ -427,6 +427,7 @@ const appsArgs: AppsPanelProps = {
}; };
const skillsArgs: SkillsPanelProps = { const skillsArgs: SkillsPanelProps = {
skillsSessionKey: "story-session",
skills: [], skills: [],
skillsPageCount: 0, skillsPageCount: 0,
skillsUi: EMPTY_SKILLS_UI, skillsUi: EMPTY_SKILLS_UI,

View file

@ -172,6 +172,7 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
...mergeBundle("apps", overrides), ...mergeBundle("apps", overrides),
}, },
skills: { skills: {
skillsSessionKey: "test-session",
skills: [], skills: [],
skillsPageCount: 0, skillsPageCount: 0,
skillsUi: EMPTY_SKILLS_UI, skillsUi: EMPTY_SKILLS_UI,

View file

@ -456,6 +456,7 @@ export function InspectorView({
onRefreshApps, onRefreshApps,
} = appsPanel; } = appsPanel;
const { const {
skillsSessionKey,
skills, skills,
skillsPageCount, skillsPageCount,
skillsLoadError, skillsLoadError,
@ -1055,6 +1056,7 @@ export function InspectorView({
onSortChange: setConsoleSort, onSortChange: setConsoleSort,
}; };
const skillsScreenProps = { const skillsScreenProps = {
sessionKey: skillsSessionKey,
skills, skills,
pageCount: skillsPageCount, pageCount: skillsPageCount,
loadError: skillsLoadError, loadError: skillsLoadError,

View file

@ -313,6 +313,12 @@ export interface AppsPanelProps {
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */ /** The Skills screen (SEP-2640): the enumerated skills and their verification. */
export interface SkillsPanelProps { export interface SkillsPanelProps {
/**
* Identity of the connected session. Part of the screen's invalidation key,
* so async verification state can never cross a connection — see
* `UseInspectorStoresResult.sessionNonce`.
*/
skillsSessionKey: string;
skills: SkillEntry[]; skills: SkillEntry[];
/** Pages the last `skills/list` walk took. */ /** Pages the last `skills/list` walk took. */
skillsPageCount: number; skillsPageCount: number;

View file

@ -257,6 +257,21 @@ describe("useInspectorStores", () => {
expect(h.api().stores).not.toBe(first); expect(h.api().stores).not.toBe(first);
}); });
it("advances the session nonce on both create and destroy", () => {
// It names one connected session and must never repeat across a
// reconnect — `SkillsScreen` keys async verification state on it, and a
// repeated value would let one session's result land in another.
const h = harness();
const seen = new Set<number>([h.api().sessionNonce]);
h.run((api) => api.createStores(client(), fetchLogOptions));
seen.add(h.api().sessionNonce);
h.run((api) => api.destroyStores());
seen.add(h.api().sessionNonce);
h.run((api) => api.createStores(client(), fetchLogOptions));
seen.add(h.api().sessionNonce);
expect(seen.size).toBe(4);
});
it("destroys and clears on destroyStores", () => { it("destroys and clears on destroyStores", () => {
const h = harness(); const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions)); h.run((api) => api.createStores(client(), fetchLogOptions));

View file

@ -86,6 +86,17 @@ export type FetchLogOptions = Pick<
export interface UseInspectorStoresResult { export interface UseInspectorStoresResult {
/** The live stores, or `null` before the first connect / after teardown. */ /** The live stores, or `null` before the first connect / after teardown. */
stores: InspectorStores | null; stores: InspectorStores | null;
/**
* Bumped on every `createStores` **and** every `destroyStores`, so it names
* one connected session and never repeats across a reconnect.
*
* Screens that hold async state keyed by *content* need this in the key:
* `SkillsScreen` stays mounted across a disconnect, so a verification still
* in flight for server A could otherwise land after a switch to server B and
* report a verdict for an identical-looking entry that was never read from B
* (#2234).
*/
sessionNonce: number;
/** /**
* Build a fresh set of stores for `client`, tearing down whatever set is * Build a fresh set of stores for `client`, tearing down whatever set is
* live first. Stable, so callers need no dependency on the current stores. * live first. Stable, so callers need no dependency on the current stores.
@ -162,6 +173,9 @@ export function useInspectorStores({
paginatedLists, paginatedLists,
}: UseInspectorStoresParams): UseInspectorStoresResult { }: UseInspectorStoresParams): UseInspectorStoresResult {
const [stores, setStores] = useState<InspectorStores | null>(null); const [stores, setStores] = useState<InspectorStores | null>(null);
// See `sessionNonce` above. A counter rather than the store object's identity
// because it has to be usable as part of a string key.
const [sessionNonce, setSessionNonce] = useState(0);
// Mirrors `stores` so `destroyStores` can read the live set without taking a // Mirrors `stores` so `destroyStores` can read the live set without taking a
// dependency on it — which is what keeps every caller's callback stable. // dependency on it — which is what keeps every caller's callback stable.
const storesRef = useRef<InspectorStores | null>(null); const storesRef = useRef<InspectorStores | null>(null);
@ -177,6 +191,7 @@ export function useInspectorStores({
storesRef.current = null; storesRef.current = null;
fetchLogRef.current = null; fetchLogRef.current = null;
setStores(null); setStores(null);
setSessionNonce((n) => n + 1);
}, []); }, []);
const createStores = useCallback( const createStores = useCallback(
@ -214,6 +229,7 @@ export function useInspectorStores({
storesRef.current = next; storesRef.current = next;
fetchLogRef.current = fetchRequestLogState; fetchLogRef.current = fetchRequestLogState;
setStores(next); setStores(next);
setSessionNonce((n) => n + 1);
}, },
[destroyStores], [destroyStores],
); );
@ -340,6 +356,7 @@ export function useInspectorStores({
return { return {
stores, stores,
sessionNonce,
createStores, createStores,
destroyStores, destroyStores,
fetchLogRef, fetchLogRef,