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
6 changed files with 135 additions and 7 deletions

View file

@ -101,6 +101,7 @@ const readFixtureFile = vi.fn(async (uri: string) => {
});
const baseProps: SkillsScreenProps = {
sessionKey: "session-1",
skills: ALL_SKILLS,
pageCount: 2,
ui: EMPTY_SKILLS_UI,
@ -718,6 +719,67 @@ describe("SkillsScreen", () => {
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 () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);

View file

@ -108,6 +108,14 @@ interface FetchedEntryState {
}
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[];
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
pageCount: number;
@ -320,6 +328,7 @@ function shortDigest(digest: string | undefined): string {
* the Inspector fetches only what the user asks it to.
*/
export function SkillsScreen({
sessionKey,
skills,
pageCount,
loadError,
@ -378,10 +387,15 @@ export function SkillsScreen({
);
}, [skills, search]);
const selected = useMemo(
() => skills.find((skill) => skill.uri === selectedSkillUri),
[skills, selectedSkillUri],
);
// Matched by IDENTITY, like every other URI comparison here: a refresh that
// canonicalizes `skill://demo/%53KILL.md` to `skill://demo/SKILL.md` names
// 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(
() => (selected ? checkSkillConformance(selected) : []),
@ -406,8 +420,9 @@ export function SkillsScreen({
// not. A primitive string, because `useValueChange` compares with `Object.is`
// and a fresh object every render would loop.
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
@ -623,7 +638,11 @@ export function SkillsScreen({
return (
<NavLink
key={skill.uri}
active={skill.uri === selectedSkillUri}
active={
selectedSkillUri !== undefined &&
skillUriIdentity(skill.uri) ===
skillUriIdentity(selectedSkillUri)
}
label={skillDisplayName(skill)}
description={skill.uri}
onClick={() =>

View file

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

View file

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

View file

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

View file

@ -313,6 +313,12 @@ export interface AppsPanelProps {
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
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[];
/** Pages the last `skills/list` walk took. */
skillsPageCount: number;