feat(web): scroll the failed server card into view on connection failure (#1621)

Opening the monitoring column on a failed connection reflows the server
grid to fewer/narrower columns, which can push the failed card below the
fold. Scroll it into view so the red-bordered card is visible without
hunting for it.

- ServerCard scrolls itself into view on the disconnected→errored
  transition (mirrors the freshly-added highlight scroll), deferred
  ~320ms so the column open + grid reflow settle first — otherwise
  scrollIntoView targets the card's pre-reflow position. Guarded by a ref
  so it fires once per failure and won't fight a user who scrolls away.

Verified live: the failed `time` card scrolls fully into view after the
column opens. Full `npm run ci` passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-09 15:05:34 -04:00
commit 71d33d70c4

View file

@ -414,5 +414,61 @@ describe("ServerCard", () => {
container.querySelector('[data-variant="highlighted"]'),
).toBeNull();
});
it("scrolls the card into view on the errored transition", () => {
vi.useFakeTimers();
const scrollIntoView = vi.fn();
const orig = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
try {
const { rerender } = renderWithMantine(
<ServerCard {...baseProps} connection={disconnected} />,
);
// No scroll while not errored.
vi.advanceTimersByTime(1000);
expect(scrollIntoView).not.toHaveBeenCalled();
// Becoming errored schedules a deferred scroll (past the column open).
rerender(
<ServerCard {...baseProps} connection={disconnected} errored />,
);
expect(scrollIntoView).not.toHaveBeenCalled();
vi.advanceTimersByTime(320);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
// A further re-render while still errored does not scroll again (so it
// won't fight a user who scrolled away).
rerender(
<ServerCard
{...baseProps}
name="Renamed"
connection={disconnected}
errored
/>,
);
vi.advanceTimersByTime(1000);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
} finally {
Element.prototype.scrollIntoView = orig;
vi.useRealTimers();
}
});
it("does not scroll when mounted already errored (no transition)", () => {
vi.useFakeTimers();
const scrollIntoView = vi.fn();
const orig = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
try {
renderWithMantine(
<ServerCard {...baseProps} connection={disconnected} errored />,
);
vi.advanceTimersByTime(1000);
expect(scrollIntoView).not.toHaveBeenCalled();
} finally {
Element.prototype.scrollIntoView = orig;
vi.useRealTimers();
}
});
});
});