From fbada318fddf0639932fe42da1bb5033bc229891 Mon Sep 17 00:00:00 2001 From: TrueNorth49 <182109737+TrueNorth49@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:25:29 +0200 Subject: [PATCH] [MC-460-A] fix(frontend): scroll active concept into view in Compare/Tags too The sidebar auto-scroll effect only depended on selectedRealizationKey. Annotate navigates by realization (which sets that key), but Compare/Tags navigate by concept via goToConceptOffset, which clears selectedRealizationKey and only updates conceptId. The effect bailed on the null key, so the concept menu never followed the selection outside Annotate. Fall back to the active concept's parent row ([data-testid=concept-parent-button-N]) when no realization is selected, and add conceptId to the dependency array. Co-Authored-By: Claude Opus 4.8 --- src/ParseUI.test.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ src/ParseUI.tsx | 17 +++++++++++------ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/ParseUI.test.tsx b/src/ParseUI.test.tsx index 1242f608..e4b309ba 100644 --- a/src/ParseUI.test.tsx +++ b/src/ParseUI.test.tsx @@ -4225,6 +4225,47 @@ describe("ParseUI", () => { await waitFor(() => expect(mockSetActiveConcept).toHaveBeenCalledWith("2")); }); + it("scrolls the active concept row into view when navigating concepts in Compare mode", async () => { + // Compare/Tags navigation clears selectedRealizationKey, so the sidebar + // auto-scroll must fall back to the active concept's parent row — otherwise + // it would only ever fire on Annotate (regression: MC-460). + window.localStorage.setItem("parse.currentMode", "compare"); + mockConfig = { + ...mockConfig!, + concepts: [ + { id: "1", label: "water" }, + { id: "2", label: "hair" }, + { id: "3", label: "fire" }, + ], + }; + + const originalScrollIntoView = Element.prototype.scrollIntoView; + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + try { + render(); + + const sidebar = await screen.findByTestId("concept-sidebar"); + fireEvent.click(within(sidebar).getByRole("button", { name: /hair/i })); + expect(screen.getByPlaceholderText(/Ask PARSE AI about hair/i)).toBeTruthy(); + scrollSpy.mockClear(); + + // No realization is selected in Compare mode, yet ArrowDown to "fire" + // should still scroll the concept-parent-button for the active concept. + fireEvent.keyDown(window, { key: "ArrowDown" }); + expect(await screen.findByPlaceholderText(/Ask PARSE AI about fire/i)).toBeTruthy(); + + await waitFor(() => { + const scrolledActiveRow = scrollSpy.mock.instances.some( + (instance) => (instance as HTMLElement)?.getAttribute?.("data-testid") === "concept-parent-button-3", + ); + expect(scrolledActiveRow).toBe(true); + }); + } finally { + Element.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("persists compare notes per concept via localStorage without requiring a blur", () => { const { unmount } = render(); const notesField = screen.getByPlaceholderText(/Add observations, etymological notes, or questions for review/i); diff --git a/src/ParseUI.tsx b/src/ParseUI.tsx index 544b48ef..5be61421 100644 --- a/src/ParseUI.tsx +++ b/src/ParseUI.tsx @@ -1808,13 +1808,18 @@ export function ParseUI() { }, [currentMode, goToConceptOffset, goToRealizationOffset, selectedRealizationKey, dispatchSelectedSidebarDelete]); useEffect(() => { - if (!selectedRealizationKey) return; - const activeChip = Array.from(document.querySelectorAll('[data-realization-key]')) - .find((element) => element.getAttribute('data-realization-key') === selectedRealizationKey); - if (typeof activeChip?.scrollIntoView === 'function') { - activeChip.scrollIntoView({ block: 'nearest' }); + // Keep the active sidebar entry in view. Annotate navigates by realization + // (selectedRealizationKey); Compare/Tags navigate by concept and clear that + // key, so fall back to the active concept's parent row there — otherwise the + // auto-scroll would only ever fire on Annotate. + const activeElement = selectedRealizationKey + ? Array.from(document.querySelectorAll('[data-realization-key]')) + .find((element) => element.getAttribute('data-realization-key') === selectedRealizationKey) + : document.querySelector(`[data-testid="concept-parent-button-${conceptId}"]`); + if (typeof activeElement?.scrollIntoView === 'function') { + activeElement.scrollIntoView({ block: 'nearest' }); } - }, [selectedRealizationKey]); + }, [selectedRealizationKey, conceptId]); const toggleSpeaker = (s: string) => { if (currentMode === 'annotate') {