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:
parent
c6999dff96
commit
71d33d70c4
2 changed files with 79 additions and 0 deletions
|
|
@ -414,5 +414,61 @@ describe("ServerCard", () => {
|
||||||
container.querySelector('[data-variant="highlighted"]'),
|
container.querySelector('[data-variant="highlighted"]'),
|
||||||
).toBeNull();
|
).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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@ const RemoveButton = Button.withProps({
|
||||||
color: "red.6",
|
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 {
|
function getTransport(config: MCPServerConfig): ServerType {
|
||||||
return config.type ?? "stdio";
|
return config.type ?? "stdio";
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +163,21 @@ export function ServerCard({
|
||||||
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
}
|
}
|
||||||
}, [highlighted, scrollOnHighlight]);
|
}, [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 transport = getTransport(config);
|
||||||
const commandOrUrl = getCommandOrUrl(config);
|
const commandOrUrl = getCommandOrUrl(config);
|
||||||
const version = info?.version;
|
const version = info?.version;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue