From 03c30fdce0e10e5ca9641bbf010ff2f1ddbfe193 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 08:11:09 -0500 Subject: [PATCH] Sync inbox hover with j/k selection Hovering an inbox row now sets it as the selected index used by the mail-client j/k keyboard shortcuts, so the keyboard cursor follows the mouse instead of disagreeing with it. Co-Authored-By: Paperclip --- ui/src/pages/Inbox.test.tsx | 58 +++++++++++++++++++++++++++++++++++++ ui/src/pages/Inbox.tsx | 11 ++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index a23af8e7c70..1c4d5b61571 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -138,6 +138,11 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect. +if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", @@ -289,6 +294,59 @@ describe("Inbox toolbar", () => { root.unmount(); }); }); + + it("syncs hover with j/k selection on inbox rows", async () => { + routerMock.location.pathname = "/inbox/mine"; + const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); + const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" }); + apiMocks.issuesList.mockResolvedValue([issueA, issueB]); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + await Promise.resolve(); + }); + + const rows = container.querySelectorAll("[data-inbox-item]"); + expect(rows.length).toBeGreaterThanOrEqual(2); + + const linkOf = (row: Element): HTMLAnchorElement | null => + row.querySelector("a[data-inbox-issue-link]"); + + // Nothing selected before hover — both rows show the hover-accent class. + expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); + expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); + + await act(async () => { + rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + + // After hovering row 1, that row is "selected" — same visual state as j/k selection. + expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent"); + expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); + + await act(async () => { + rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + + // Hovering a different row moves the selection to follow the mouse. + expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent"); + expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); }); describe("FailedRunInboxRow", () => { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 7f5db1a5b8c..2debaa92604 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -2440,6 +2440,9 @@ export function Inbox() { onClick={() => { if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx); }} + onMouseEnter={() => { + if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx); + }} > setSelectedIndex(navIdx)} + onMouseEnter={() => setSelectedIndex(navIdx)} > {child} @@ -2643,7 +2647,12 @@ export function Inbox() { key={`sel-issue:${child.id}`} data-inbox-item className="relative" - onClick={() => setSelectedIndex(childNavIdx)} + onClick={() => { + if (childNavIdx >= 0) setSelectedIndex(childNavIdx); + }} + onMouseEnter={() => { + if (childNavIdx >= 0) setSelectedIndex(childNavIdx); + }} > {canArchiveIssue ? (