diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 8ef3e5efe8..08d319750c 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1239,9 +1239,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView return byName; }, [mentionAgents]); + // Key the reset on skill ids, not array identity: useDiscoveredSkillsCache + // (SWR) re-delivers content-identical lists with fresh identities (cache + // reads re-parse; revalidation notifies a new array). Resetting on identity + // alone wipes the user's keyboard highlight mid-navigation when a + // revalidation lands — only a *semantic* list change should reset it. + const filteredSkillsKey = useMemo( + () => filteredSkills.map((skill) => skill.id).join(""), + [filteredSkills], + ); useEffect(() => { setHighlightedSkillIndex(0); - }, [filteredSkills]); + }, [filteredSkillsKey]); useEffect(() => { setMentionHighlightIndex(0); diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index e5edcd3f96..9280decbb6 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -19,6 +19,7 @@ import * as useChatModule from "../../hooks/useChat"; import type { UseChatReturn, ChatSessionInfo, ChatMessageInfo, ToolCallInfo } from "../../hooks/useChat"; import * as apiModule from "../../api"; import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; +import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; import type { UseChatRoomsResult } from "../../hooks/useChatRooms"; @@ -1844,6 +1845,47 @@ describe("ChatView", () => { await waitFor(() => expect(textarea).toHaveValue("/skill:gamma ")); }); + it("keeps the keyboard highlight when revalidation re-delivers an identical skill list", async () => { + // Regression: the SWR skills cache re-delivers content-identical lists + // with fresh array identities (cache reads re-parse; revalidation + // notifies a new array). The highlight reset must key on skill ids, not + // array identity, or a revalidation landing mid-navigation wipes the + // user's keyboard position (the source of this test family's CI flakes). + const skillsList = [ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), + ]; + // Seed the cache so the menu renders before the (deferred) revalidation fetch. + writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList); + let resolveFetch!: (skills: DiscoveredSkill[]) => void; + mockFetchDiscoveredSkills.mockImplementationOnce( + () => new Promise((resolve) => { resolveFetch = resolve; }), + ); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + render(); + + const textarea = screen.getByTestId("chat-input"); + fireEvent.change(textarea, { target: { value: "/" } }); + await screen.findByRole("option", { name: /alpha/i }); + + fireEvent.keyDown(textarea, { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ), + ); + + // Revalidation lands mid-navigation: identical content, new identity. + await act(async () => { + resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]); + }); + + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ); + }); + it("supports selecting highlighted skill with Tab", async () => { mockFetchDiscoveredSkills.mockResolvedValueOnce([ createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),