diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx
index 29b7bf91..0ad12edc 100644
--- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx
+++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx
@@ -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(
+ ,
+ );
+ // No scroll while not errored.
+ vi.advanceTimersByTime(1000);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ // Becoming errored schedules a deferred scroll (past the column open).
+ rerender(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ vi.advanceTimersByTime(1000);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ } finally {
+ Element.prototype.scrollIntoView = orig;
+ vi.useRealTimers();
+ }
+ });
});
});
diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx
index 6d2bfaa0..54fd2e78 100644
--- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx
+++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx
@@ -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;