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
2 changed files with 79 additions and 0 deletions

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();
}
});
});
});

View file

@ -101,6 +101,14 @@ const RemoveButton = Button.withProps({
color: "red.6",
});
// Delay before scrolling a newly-failed card into view (#1621). A connection
// failure both flags the card and opens the monitoring column, which reflows
// the server grid (fewer, narrower columns) and can push the failed card below
// the fold. Deferring past the column's open lets that reflow settle so
// `scrollIntoView` targets the card's final position rather than its pre-reflow
// one. Kept just above the column's slide duration.
const ERROR_SCROLL_DELAY_MS = 320;
function getTransport(config: MCPServerConfig): ServerType {
return config.type ?? "stdio";
}
@ -155,6 +163,21 @@ export function ServerCard({
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlighted, scrollOnHighlight]);
// Scroll the failed card into view on the disconnected→errored transition
// (#1621), deferred so the monitoring column's open + grid reflow settle
// first. Guarded by a ref so a re-render while still errored doesn't re-scroll
// and fight the user if they've scrolled away.
const wasErroredRef = useRef(errored);
useEffect(() => {
const justErrored = errored && !wasErroredRef.current;
wasErroredRef.current = errored;
if (!justErrored) return;
const timer = setTimeout(() => {
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, ERROR_SCROLL_DELAY_MS);
return () => clearTimeout(timer);
}, [errored]);
const transport = getTransport(config);
const commandOrUrl = getCommandOrUrl(config);
const version = info?.version;