feat(web): show Apps, Prompts, Resources, and Tasks tabs only when the server exposes the relevant items (#1450) (#1453)
* feat(web): show Apps tab only when server exposes MCP App tools (#1450) Gate the "Apps" tab on appTools.length > 0 so it is hidden when the connected server exposes no MCP App tools. appTools is now computed before availableTabs, which depends on it — the tab appears/disappears live as the tool list changes (list-changed refresh, server switch). The existing activeTab fallback already snaps a user sitting on Apps back to Servers when the tab disappears. When app tools exist but the sandbox is unavailable the tab stays visible so the AppsScreen's "unavailable" message remains reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): content-gate the Prompts and Resources tabs too (#1450) Extend the Apps tab gating to Prompts and Resources: each tab is hidden when its list is empty and appears reactively when the list gains an entry (list-changed refresh, server switch). Resources is gated on resources OR templates, since a server may expose only templates. The existing activeTab fallback snaps a user on a now-empty tab back to Servers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): content-gate the Tasks tab too (#1450) Extend the content gating to the Tasks tab: hidden when no tasks exist, appears reactively once a task-augmented tool call creates one and disappears when all are cleared. Consistent with the Apps/Prompts/ Resources gates — the existing activeTab fallback snaps a user on a now- empty tab back to Servers. The "run as task" affordance still lives on the Tools screen (gated by server task support), so discovery is unaffected; advertised capabilities remain visible in Connection Info. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): fade content-gated tabs in to draw attention on appearance (#1450) Add a brief 325ms fade-in to navigation tab labels via a new `tabLabel` Text theme variant + `inspector-fade-in` keyframe. SegmentedControl keys segments by value, so when a content-gated tab (Apps/Prompts/Resources/ Tasks) appears after a list change its label mounts fresh and fades in to draw the eye, while existing tabs stay put. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): animate tab-bar width when a tab is added or removed (#1450) Wrap the tab SegmentedControl in a clip element (new `tabBar` Group variant) whose width is set to the control's measured border-box size and transitions over 325ms ease-in. The control is sized `max-content` and the clip `overflow: hidden`, so when a content-gated tab appears or disappears the bar grows/shrinks smoothly. Width is measured with useElementSize (ResizeObserver); it falls back to auto width where the observer is unavailable, so the bar still renders at its natural size. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): lengthen tab-label fade-in to 450ms (#1450) Bump the `tabLabel` fade from 325ms to 450ms so a newly-appearing tab name fades in more gently. Also formats the previously-unformatted ViewHeader width-clip test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): drop tab-label fade-in, keep only the 325ms width animation (#1450) Remove the per-tab fade-in (tabLabel Text variant + inspector-fade-in keyframe + label wrapping) so the tab bar's 325ms ease-in width change is the sole animation when a content-gated tab appears or disappears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): collapse the tab bar to 0 width when it leaves the DOM on disconnect (#1450) Unify the connected/disconnected ViewHeader into a single tree so the tab controls can sit in a keep-alive Mantine Transition. On disconnect the Transition holds the bar mounted for 325ms while the clip's CSS width transition collapses it measured->0, then unmounts. The connected tab list/selection is snapshotted (adjust-state-during-render, no refs) so the bar keeps rendering its tabs as it shrinks. The disconnected header keeps its logo-left / title-center / theme-right layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): fade the disconnected title in after the tab bar collapses (#1450) On disconnect, hold the "MCP Inspector" title until the tab bar's shrink-to-0 exit finishes (tracked via the keep-alive Transition's onExited), then fade it in over 300ms. Removes the prior overlap where the title appeared beside the still-collapsing bar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): set both disconnect animations to 300ms (#1450) Drop the tab-bar collapse from 325ms to 300ms so it matches the title fade (also 300ms), trimming the overall disconnect sequence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): staggered fade+slide-down crossfade between title and tab bar (#1450) Replace the tab-bar width/size animation with a staggered crossfade. The disconnected "MCP Inspector" title and the connected tab bar share one CSS grid cell, so on connect/disconnect one fades + slides down out while the other fades + slides down in — the incoming one delayed by half the 300ms duration so they're staggered and one replaces the other in place. Both motions travel downward (enter descends from above, exit descends below) via inspector-fade-slide-in/out keyframes, selected per element with a data-anim attribute; Mantine Transition provides keep-alive so the exit plays before unmount. Removes the clip/useElementSize/tabBar-variant size-animation machinery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): pulse a red glow on a tab that newly appears mid-session (#1450) When a content-gated tab is added to the bar while connected, pulse a red text glow around its label in-then-out over 325ms. New tabs are detected by comparing the shown set to the previous render's (adjust-state-during- render), so only mid-session additions glow — not the initial set on connect, nor anything on disconnect. The glow is a CSS keyframe (inspector-tab-glow) applied via a `tabGlow` Text variant + `data-glow` attribute; the red comes from a new --inspector-glow-red token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): start the new-tab glow at full intensity, then fade (#1450) Change the tab glow from an in-then-out pulse to full-on-appear then fade over 325ms (ease-out), so a newly added tab flashes brightest the instant it appears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): keep new-tab label text red through the glow, then revert to theme (#1450) Animate `color` red across the whole glow keyframe so a newly-appeared tab's text stays red for the 325ms; with no forwards fill it snaps back to the label's theme color when the animation ends. The glow text-shadow still fades over the duration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): revert new-tab text color animation, keep glow only (#1450) Drop the `color: red` keyframe stops so the label text keeps its theme color; only the red glow (text-shadow) plays, full intensity on appear then fading over 325ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): make the new-tab glow ease-in over 325ms (#1450) Change the tab glow timing function from ease-out to ease-in (duration already 325ms). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): animate server name and right-side controls on connect/disconnect (#1450) Wrap the server name (left) and the status indicator + Disconnect control (right) in keep-alive Transitions so they fade + slide-down in on connect and out on disconnect, using the same values as the tab bar. Split the animation CSS (.header-anim[data-anim]) from the center-stack grid placement so it's reusable, and broaden the snapshot to retain serverName/ status/latency for rendering during exit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): address review — glow grace window, Select comment, key unification (#1450) - Arm the new-tab glow only after a GLOW_GRACE_MS window post-connect, so asynchronously-resolved lists (prompts/resources/tasks) aren't mistaken for mid-session additions and don't glow on initial load. - Document why the narrow-viewport Select doesn't glow. - Route snapshotKey and the glow key through a shared tabsKey()/TAB_SEP helper (also removes a stray control char from the join separator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(web): note the slow-server tail of the glow grace window (#1450) Acknowledge in a comment that GLOW_GRACE_MS keys on time-since-connect, not on lists having settled, so a server slower than the window glows on its tail. Per review follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): honor prefers-reduced-motion for the new header animations (#1450) Add a reduced-motion media query that disables the header fade/slide (.header-anim) and the new-tab glow (.tab-glow), leaving elements at their resting state. Per review accessibility suggestion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0890c7509a
commit
a10ced988c
6 changed files with 855 additions and 85 deletions
|
|
@ -42,6 +42,9 @@
|
|||
--inspector-status-disconnected: var(--mantine-color-gray-5);
|
||||
--inspector-status-error: var(--mantine-color-red-6);
|
||||
|
||||
/* Red glow pulsed around a newly appearing tab's label (#1450) */
|
||||
--inspector-glow-red: var(--mantine-color-red-6);
|
||||
|
||||
/* ── Log levels (RFC 5424) ─────────────────────────────── */
|
||||
--inspector-log-debug: var(--mantine-color-gray-6);
|
||||
--inspector-log-info: var(--mantine-color-blue-6);
|
||||
|
|
@ -93,6 +96,43 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Both motions travel downward: enter descends from above into place, exit
|
||||
descends from place to below — used for the header crossfade (#1450). */
|
||||
@keyframes inspector-fade-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes inspector-fade-slide-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* A red glow around a tab label's text that starts at full intensity the
|
||||
moment a content-gated tab appears and fades out (#1450). */
|
||||
@keyframes inspector-tab-glow {
|
||||
from {
|
||||
text-shadow:
|
||||
0 0 6px var(--inspector-glow-red),
|
||||
0 0 12px var(--inspector-glow-red);
|
||||
}
|
||||
to {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status dot animations ───────────────────────────────── */
|
||||
|
||||
.inspector-pulse {
|
||||
|
|
@ -103,6 +143,45 @@
|
|||
animation: inspector-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Applied (via the `tabGlow` Text variant) to a tab label; the glow starts full
|
||||
and fades, running only while `data-glow="on"` on a freshly-appeared tab. */
|
||||
.tab-glow[data-glow="on"] {
|
||||
animation: inspector-tab-glow 325ms ease-in;
|
||||
}
|
||||
|
||||
/* ── Header connect/disconnect animations (#1450) ────────── */
|
||||
/* Any header region that appears on connect / leaves on disconnect — the
|
||||
center title↔tab-bar crossfade, the server name (left), and the status +
|
||||
Disconnect controls (right). The element leaving ([data-anim="out"]) slides
|
||||
down + fades out immediately; the appearing one ([data-anim="in"]) is delayed
|
||||
by half the duration so they're staggered. Keep the 300ms/150ms here in sync
|
||||
with HEADER_ANIM_MS in ViewHeader.tsx. */
|
||||
.header-anim[data-anim="in"] {
|
||||
animation: inspector-fade-slide-in 300ms ease 150ms both;
|
||||
}
|
||||
|
||||
.header-anim[data-anim="out"] {
|
||||
animation: inspector-fade-slide-out 300ms ease both;
|
||||
}
|
||||
|
||||
/* The title and tab bar additionally share one grid cell so one replaces the
|
||||
other in place during the center crossfade. */
|
||||
.header-stack-cell {
|
||||
grid-area: 1 / 1;
|
||||
place-self: center;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion for the header animations this PR adds (#1450): skip the
|
||||
fade/slide and the new-tab glow, leaving the elements at their resting state.
|
||||
Selectors match the originals' specificity so they win inside the query. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header-anim[data-anim="in"],
|
||||
.header-anim[data-anim="out"],
|
||||
.tab-glow[data-glow="on"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── List item hover ────────────────────────────────────── */
|
||||
|
||||
.list-item:hover {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import {
|
||||
act,
|
||||
renderWithMantine,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "../../../test/renderWithMantine";
|
||||
import { ViewHeader } from "./ViewHeader";
|
||||
|
||||
// Mock @mantine/hooks so we can control useMediaQuery results per test.
|
||||
|
|
@ -118,6 +123,120 @@ describe("ViewHeader", () => {
|
|||
expect(radios.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("animates the connected tab bar in with the slide-down enter (#1450)", () => {
|
||||
mediaQueryMock.value = true;
|
||||
renderWithMantine(<ViewHeader {...connectedProps} />);
|
||||
// The tab bar lives in a crossfade cell marked for the enter animation.
|
||||
const cell = screen.getAllByRole("radio")[0]!.closest("[data-anim]");
|
||||
expect(cell?.getAttribute("data-anim")).toBe("in");
|
||||
});
|
||||
|
||||
it("crossfades on disconnect: tab bar exits while the title enters, then the bar unmounts (#1450)", async () => {
|
||||
mediaQueryMock.value = true;
|
||||
const { rerender } = renderWithMantine(
|
||||
<ViewHeader {...connectedProps} />,
|
||||
);
|
||||
expect(screen.getAllByRole("radio").length).toBeGreaterThan(0);
|
||||
|
||||
// Disconnect: the connected header is replaced, but the tab bar stays in
|
||||
// the DOM (now marked for the exit animation) until the keep-alive
|
||||
// Transition's exit window elapses — so it isn't removed synchronously.
|
||||
rerender(<ViewHeader connected={false} onToggleTheme={vi.fn()} />);
|
||||
const exitingCell = screen
|
||||
.getAllByRole("radio")[0]!
|
||||
.closest("[data-anim]");
|
||||
expect(exitingCell?.getAttribute("data-anim")).toBe("out");
|
||||
|
||||
// The title enters (staggered) in its own cell.
|
||||
const title = await screen.findByText("MCP Inspector");
|
||||
expect(title.closest("[data-anim]")?.getAttribute("data-anim")).toBe(
|
||||
"in",
|
||||
);
|
||||
|
||||
// After the exit transition the tab bar is removed from the DOM entirely.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("radio").length).toBe(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("glows a tab added after the connect grace window, not during it (#1450)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mediaQueryMock.value = true;
|
||||
const { rerender } = renderWithMantine(
|
||||
<ViewHeader {...connectedProps} />,
|
||||
);
|
||||
// During the post-connect grace window an async-resolved list (adding
|
||||
// "Apps") counts as part of the initial set and does not glow.
|
||||
rerender(
|
||||
<ViewHeader
|
||||
{...connectedProps}
|
||||
availableTabs={["Tools", "Apps", "Resources", "Prompts"]}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector('[data-glow="on"]')).toBeNull();
|
||||
|
||||
// Once the grace window elapses, a genuine mid-session addition glows.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
rerender(
|
||||
<ViewHeader
|
||||
{...connectedProps}
|
||||
availableTabs={["Tools", "Apps", "Resources", "Prompts", "Tasks"]}
|
||||
/>,
|
||||
);
|
||||
const glowing = document.querySelectorAll('[data-glow="on"]');
|
||||
expect(glowing.length).toBe(1);
|
||||
expect(glowing[0]?.textContent).toBe("Tasks");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("animates the server name and disconnect controls in on connect, out on disconnect (#1450)", async () => {
|
||||
mediaQueryMock.value = true;
|
||||
const { rerender } = renderWithMantine(
|
||||
<ViewHeader {...connectedProps} />,
|
||||
);
|
||||
// Connected: the server name and the Disconnect control are in their
|
||||
// enter cells.
|
||||
expect(
|
||||
screen
|
||||
.getByText("my-mcp-server")
|
||||
.closest("[data-anim]")
|
||||
?.getAttribute("data-anim"),
|
||||
).toBe("in");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Disconnect" })
|
||||
.closest("[data-anim]")
|
||||
?.getAttribute("data-anim"),
|
||||
).toBe("in");
|
||||
|
||||
// Disconnect: both stay mounted (animating out) before unmounting.
|
||||
rerender(<ViewHeader connected={false} onToggleTheme={vi.fn()} />);
|
||||
expect(
|
||||
screen
|
||||
.getByText("my-mcp-server")
|
||||
.closest("[data-anim]")
|
||||
?.getAttribute("data-anim"),
|
||||
).toBe("out");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Disconnect" })
|
||||
.closest("[data-anim]")
|
||||
?.getAttribute("data-anim"),
|
||||
).toBe("out");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Disconnect" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onTabChange when a different tab is picked from the Select", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTabChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
|
|
@ -8,6 +10,7 @@ import {
|
|||
Select,
|
||||
Text,
|
||||
Title,
|
||||
Transition,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
|
@ -37,6 +40,77 @@ interface UnconnectedProps {
|
|||
|
||||
export type ViewHeaderProps = ConnectedProps | UnconnectedProps;
|
||||
|
||||
// Keep-alive window for the header connect/disconnect animations (#1450): the
|
||||
// center title↔tab-bar crossfade plus the server name (left) and status +
|
||||
// Disconnect controls (right) all fade + slide-down, staggered by half this
|
||||
// duration. The motion itself is CSS (`.header-anim`); keep the 300ms /
|
||||
// 150ms-stagger there in sync with this value.
|
||||
const HEADER_ANIM_MS = 300;
|
||||
// Fixed Select width on narrow viewports (fits the longest tab label).
|
||||
const SELECT_WIDTH = 140;
|
||||
// Grace window after a connection is established before the new-tab glow arms
|
||||
// (#1450). Primitive lists (prompts/resources/tasks) are fetched asynchronously
|
||||
// just after the handshake, so their tabs appear a few renders into the
|
||||
// connection; without this window they'd be mistaken for mid-session additions
|
||||
// and glow on initial load. Tabs that resolve within this window are treated as
|
||||
// part of the initial set and don't glow.
|
||||
//
|
||||
// This keys on time-since-connect, not on lists having settled, so it's a
|
||||
// heuristic: a server whose lists resolve slower than this still glows on its
|
||||
// slow tail, and a fast server pays a small arming delay. 1s is a reasonable
|
||||
// middle ground; if the InspectorClient ever exposes an "initial fetches done"
|
||||
// signal, gating on that would be more robust than a fixed timer.
|
||||
const GLOW_GRACE_MS = 1000;
|
||||
|
||||
// Tab names are single words, so newline is a safe join separator for the
|
||||
// "tabs seen / shown" keys.
|
||||
const TAB_SEP = "\n";
|
||||
|
||||
function tabsKey(tabs: string[]): string {
|
||||
return tabs.join(TAB_SEP);
|
||||
}
|
||||
|
||||
// Connected-header display data retained so each region can keep rendering while
|
||||
// it animates out after disconnect (the live props are gone by then).
|
||||
interface HeaderSnapshot {
|
||||
serverName: string;
|
||||
status: ConnectionStatus;
|
||||
latencyMs?: number;
|
||||
activeTab: string;
|
||||
availableTabs: string[];
|
||||
}
|
||||
|
||||
// Value-key for a snapshot, so re-snapshotting compares by content (fresh array
|
||||
// references each render won't trigger an update loop).
|
||||
function snapshotKey(s: HeaderSnapshot): string {
|
||||
return [
|
||||
s.serverName,
|
||||
s.status,
|
||||
s.latencyMs ?? "",
|
||||
s.activeTab,
|
||||
tabsKey(s.availableTabs),
|
||||
].join(TAB_SEP);
|
||||
}
|
||||
|
||||
// Tab label that can pulse a red glow when it newly appears (#1450). The glow
|
||||
// fires only while `data-glow="on"`; the `tabGlow` variant supplies the class
|
||||
// and the keyframe/trigger live in App.css.
|
||||
const TabGlowLabel = Text.withProps({ span: true, variant: "tabGlow" });
|
||||
|
||||
// SegmentedControl data with each label wrapped so the freshly-added tabs
|
||||
// (`glowing`) pulse on mount. `value` (used for selection and by tests) stays
|
||||
// the plain tab string.
|
||||
function toGlowingTabData(tabs: string[], glowing: string[]) {
|
||||
return tabs.map((tab) => ({
|
||||
value: tab,
|
||||
label: (
|
||||
<TabGlowLabel data-glow={glowing.includes(tab) ? "on" : undefined}>
|
||||
{tab}
|
||||
</TabGlowLabel>
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
const HeaderBar = Group.withProps({
|
||||
h: "100%",
|
||||
px: "md",
|
||||
|
|
@ -64,7 +138,10 @@ const LogoImage = Image.withProps({
|
|||
fit: "contain",
|
||||
});
|
||||
|
||||
// Server name carries the `header-anim` class so it fades + slides on
|
||||
// connect/disconnect (the `data-anim` direction is set per render).
|
||||
const ServerName = Text.withProps({
|
||||
className: "header-anim",
|
||||
fw: 600,
|
||||
size: "lg",
|
||||
truncate: "end",
|
||||
|
|
@ -72,11 +149,6 @@ const ServerName = Text.withProps({
|
|||
flex: 1,
|
||||
});
|
||||
|
||||
const CenterSection = Group.withProps({
|
||||
wrap: "nowrap",
|
||||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
const RightSection = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
|
|
@ -85,6 +157,14 @@ const RightSection = Group.withProps({
|
|||
justify: "flex-end",
|
||||
});
|
||||
|
||||
// The status indicator + Disconnect control animate as one unit on
|
||||
// connect/disconnect (the theme toggle stays put outside it).
|
||||
const RightConnectedGroup = Group.withProps({
|
||||
className: "header-anim",
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const DisconnectButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
c: "red",
|
||||
|
|
@ -104,77 +184,196 @@ const ThemeToggle = ActionIcon.withProps({
|
|||
"aria-label": "Toggle color scheme",
|
||||
});
|
||||
|
||||
const UnconnectedBar = Group.withProps({
|
||||
h: "100%",
|
||||
px: "md",
|
||||
justify: "space-between",
|
||||
});
|
||||
|
||||
export function ViewHeader(props: ViewHeaderProps) {
|
||||
const colorScheme = useComputedColorScheme();
|
||||
const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode;
|
||||
const showSegmented = useMediaQuery("(min-width: 992px)");
|
||||
const showDisconnectLabel = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
if (!props.connected) {
|
||||
return (
|
||||
<UnconnectedBar>
|
||||
<LogoLink>
|
||||
<LogoImage src={colorScheme === "dark" ? mcpLogoDark : mcpLogo} />
|
||||
</LogoLink>
|
||||
<Title order={2}>MCP Inspector</Title>
|
||||
<ThemeToggle onClick={props.onToggleTheme}>
|
||||
<ThemeIcon size={20} />
|
||||
</ThemeToggle>
|
||||
</UnconnectedBar>
|
||||
// Retain the latest connected display data so each region can keep rendering
|
||||
// it while animating out after disconnect (#1450). Uses React's "adjust state
|
||||
// during render" pattern, re-set only when the values change (compared by key)
|
||||
// so stable inputs don't loop. Callbacks aren't snapshotted — an exiting
|
||||
// header isn't interactive.
|
||||
const liveSnapshot: HeaderSnapshot | null = props.connected
|
||||
? {
|
||||
serverName: props.serverInfo.name,
|
||||
status: props.status,
|
||||
latencyMs: props.latencyMs,
|
||||
activeTab: props.activeTab,
|
||||
availableTabs: props.availableTabs,
|
||||
}
|
||||
: null;
|
||||
const [snapshot, setSnapshot] = useState<HeaderSnapshot | null>(liveSnapshot);
|
||||
if (
|
||||
liveSnapshot &&
|
||||
snapshotKey(liveSnapshot) !== (snapshot ? snapshotKey(snapshot) : "")
|
||||
) {
|
||||
setSnapshot(liveSnapshot);
|
||||
}
|
||||
const headerData = props.connected ? liveSnapshot : snapshot;
|
||||
const handleTabChange = props.connected ? props.onTabChange : undefined;
|
||||
const handleDisconnect = props.connected ? props.onDisconnect : undefined;
|
||||
|
||||
// The glow only arms once the connection has settled (GLOW_GRACE_MS after
|
||||
// connect), so tabs whose lists resolve asynchronously just after the
|
||||
// handshake count as the initial set and don't glow. Reset on disconnect via
|
||||
// adjust-state-during-render; armed by the timer effect below.
|
||||
const [glowArmed, setGlowArmed] = useState(false);
|
||||
if (!props.connected && glowArmed) {
|
||||
setGlowArmed(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!props.connected) return;
|
||||
const id = setTimeout(() => setGlowArmed(true), GLOW_GRACE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [props.connected]);
|
||||
|
||||
// Track which tabs newly appeared so their labels pulse a red glow (#1450).
|
||||
// Compared against the previous shown set via adjust-state-during-render, so
|
||||
// only tabs added mid-session glow — not the initial set on connect, the
|
||||
// async-resolved lists during the grace window, nor anything on disconnect.
|
||||
// `glowing` persists in committed state (it isn't cleared in the same render)
|
||||
// so the class survives to the DOM.
|
||||
const liveTabs = props.connected ? props.availableTabs : [];
|
||||
const liveTabsKey = tabsKey(liveTabs);
|
||||
const [seenTabsKey, setSeenTabsKey] = useState(liveTabsKey);
|
||||
const [glowing, setGlowing] = useState<string[]>([]);
|
||||
if (liveTabsKey !== seenTabsKey) {
|
||||
const prev = seenTabsKey ? seenTabsKey.split(TAB_SEP) : [];
|
||||
setSeenTabsKey(liveTabsKey);
|
||||
setGlowing(
|
||||
glowArmed && prev.length ? liveTabs.filter((t) => !prev.includes(t)) : [],
|
||||
);
|
||||
}
|
||||
|
||||
// `in` while present/entering, `out` while exiting — drives the slide-down
|
||||
// direction for every connect/disconnect-animated region.
|
||||
const connectedAnim = props.connected ? "in" : "out";
|
||||
|
||||
const logoSrc = colorScheme === "dark" ? mcpLogoDark : mcpLogo;
|
||||
|
||||
return (
|
||||
<HeaderBar>
|
||||
<LeftSection>
|
||||
<LogoLink>
|
||||
<LogoImage src={colorScheme === "dark" ? mcpLogoDark : mcpLogo} />
|
||||
<LogoImage src={logoSrc} />
|
||||
</LogoLink>
|
||||
<ServerName>{props.serverInfo.name}</ServerName>
|
||||
<Transition
|
||||
mounted={props.connected}
|
||||
transition="fade"
|
||||
duration={HEADER_ANIM_MS}
|
||||
exitDuration={HEADER_ANIM_MS}
|
||||
>
|
||||
{() =>
|
||||
headerData ? (
|
||||
<ServerName data-anim={connectedAnim}>
|
||||
{headerData.serverName}
|
||||
</ServerName>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
</Transition>
|
||||
</LeftSection>
|
||||
|
||||
<CenterSection>
|
||||
{showSegmented ? (
|
||||
<SegmentedControl
|
||||
value={props.activeTab}
|
||||
onChange={props.onTabChange}
|
||||
data={props.availableTabs}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={props.activeTab}
|
||||
onChange={(value) => value && props.onTabChange(value)}
|
||||
data={props.availableTabs}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
// Sized to comfortably fit the longest current tab label
|
||||
// ("Resources"). Revisit if longer tabs are added.
|
||||
w={140}
|
||||
/>
|
||||
)}
|
||||
</CenterSection>
|
||||
{/* CSS grid stack: the title and tab bar cells share one cell (grid-area
|
||||
1/1 via `.header-stack-cell`), so on connect/disconnect one
|
||||
fades+slides out as the other fades+slides in, in the same place.
|
||||
`flex: 0 0 auto` keeps it from stretching within the header. */}
|
||||
<Box display="grid" flex="0 0 auto">
|
||||
{/* The Transitions are keep-alive only: when `mounted` flips false the
|
||||
cell stays in the DOM for `exitDuration` while its CSS exit animation
|
||||
(`data-anim="out"`) plays, then unmounts. `data-anim` selects the
|
||||
slide-down direction (in = descend from above, out = descend below);
|
||||
the incoming cell's CSS delay staggers it behind the outgoing one. */}
|
||||
<Transition
|
||||
mounted={props.connected}
|
||||
transition="fade"
|
||||
duration={HEADER_ANIM_MS}
|
||||
exitDuration={HEADER_ANIM_MS}
|
||||
>
|
||||
{() =>
|
||||
headerData ? (
|
||||
<Box
|
||||
className="header-anim header-stack-cell"
|
||||
data-anim={connectedAnim}
|
||||
>
|
||||
{showSegmented ? (
|
||||
<SegmentedControl
|
||||
value={headerData.activeTab}
|
||||
onChange={handleTabChange}
|
||||
data={toGlowingTabData(headerData.availableTabs, glowing)}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
// Narrow viewport: the new-tab glow doesn't apply to the
|
||||
// dropdown (a collapsed Select can't pulse a single option),
|
||||
// so the labels stay plain strings here.
|
||||
<Select
|
||||
value={headerData.activeTab}
|
||||
onChange={(value) => value && handleTabChange?.(value)}
|
||||
data={headerData.availableTabs}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
w={SELECT_WIDTH}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// Unreachable in practice — the Transition only mounts while
|
||||
// connected, by which point the snapshot is set — but the render
|
||||
// prop must return an element, never null.
|
||||
<></>
|
||||
)
|
||||
}
|
||||
</Transition>
|
||||
<Transition
|
||||
mounted={!props.connected}
|
||||
transition="fade"
|
||||
duration={HEADER_ANIM_MS}
|
||||
exitDuration={HEADER_ANIM_MS}
|
||||
>
|
||||
{() => (
|
||||
<Box
|
||||
className="header-anim header-stack-cell"
|
||||
data-anim={props.connected ? "out" : "in"}
|
||||
>
|
||||
<Title order={2}>MCP Inspector</Title>
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
</Box>
|
||||
|
||||
<RightSection>
|
||||
<ServerStatusIndicator
|
||||
status={props.status}
|
||||
latencyMs={props.latencyMs}
|
||||
/>
|
||||
{showDisconnectLabel ? (
|
||||
<DisconnectButton onClick={props.onDisconnect}>
|
||||
Disconnect
|
||||
</DisconnectButton>
|
||||
) : (
|
||||
<DisconnectIcon onClick={props.onDisconnect} title="Disconnect">
|
||||
<MdLinkOff size={20} />
|
||||
</DisconnectIcon>
|
||||
)}
|
||||
<Transition
|
||||
mounted={props.connected}
|
||||
transition="fade"
|
||||
duration={HEADER_ANIM_MS}
|
||||
exitDuration={HEADER_ANIM_MS}
|
||||
>
|
||||
{() =>
|
||||
headerData ? (
|
||||
<RightConnectedGroup data-anim={connectedAnim}>
|
||||
<ServerStatusIndicator
|
||||
status={headerData.status}
|
||||
latencyMs={headerData.latencyMs}
|
||||
/>
|
||||
{showDisconnectLabel ? (
|
||||
<DisconnectButton onClick={handleDisconnect}>
|
||||
Disconnect
|
||||
</DisconnectButton>
|
||||
) : (
|
||||
<DisconnectIcon onClick={handleDisconnect} title="Disconnect">
|
||||
<MdLinkOff size={20} />
|
||||
</DisconnectIcon>
|
||||
)}
|
||||
</RightConnectedGroup>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
</Transition>
|
||||
<ThemeToggle onClick={props.onToggleTheme}>
|
||||
<ThemeIcon size={20} />
|
||||
</ThemeToggle>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import type {
|
||||
InitializeResult,
|
||||
Prompt,
|
||||
Resource,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
|
|
@ -156,6 +159,31 @@ const connectedInit: InitializeResult = {
|
|||
serverInfo: { name: "Alpha", version: "1.0.0" },
|
||||
};
|
||||
|
||||
// A tool the `isAppTool` filter recognizes (it carries `_meta.ui.resourceUri`),
|
||||
// so its presence in the tool list makes the Apps tab available (#1450).
|
||||
const sampleAppTool: Tool = {
|
||||
name: "ops",
|
||||
title: "Ops Dashboard",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||
};
|
||||
|
||||
// Prompts, Resources, and Tasks tabs are content-gated like Apps (#1450):
|
||||
// each is hidden until its list has an entry. These fixtures populate the
|
||||
// lists so the associated tab is available.
|
||||
const samplePrompt: Prompt = { name: "greet" };
|
||||
const sampleResource: Resource = {
|
||||
uri: "file:///readme.md",
|
||||
name: "README",
|
||||
};
|
||||
const sampleTask: Task = {
|
||||
taskId: "d0b22eba71fa36229ce5c4dfadeaa7de",
|
||||
status: "working",
|
||||
ttl: 300000,
|
||||
createdAt: "2026-03-29T20:18:20Z",
|
||||
lastUpdatedAt: "2026-03-29T20:18:22Z",
|
||||
};
|
||||
|
||||
describe("InspectorView", () => {
|
||||
it("renders the empty-server-list placeholder when no servers are configured", () => {
|
||||
renderWithMantine(<InspectorView {...makeProps()} />);
|
||||
|
|
@ -353,12 +381,6 @@ describe("InspectorView", () => {
|
|||
|
||||
it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const opsApp: Tool = {
|
||||
name: "ops",
|
||||
title: "Ops Dashboard",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||
};
|
||||
// Plain (non-app) tool plus a tool with a malformed UI resource URI
|
||||
// exercise both branches of the appTools filter: the non-app drop and
|
||||
// the try/catch around `isAppTool` for malformed metadata.
|
||||
|
|
@ -381,7 +403,7 @@ describe("InspectorView", () => {
|
|||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
latencyMs: 50,
|
||||
tools: [opsApp, plainTool, malformedAppTool],
|
||||
tools: [sampleAppTool, plainTool, malformedAppTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -393,6 +415,313 @@ describe("InspectorView", () => {
|
|||
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Apps tab when the server exposes no MCP App tools", async () => {
|
||||
const plainTool: Tool = {
|
||||
name: "shell.exec",
|
||||
title: "Run Shell",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
// Only a non-app tool — no `_meta.ui.resourceUri`, so appTools is empty.
|
||||
tools: [plainTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Tools");
|
||||
expect(labels).not.toContain("Apps");
|
||||
});
|
||||
|
||||
it("shows the Apps tab when the server exposes one or more MCP App tools", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tools: [sampleAppTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Apps");
|
||||
});
|
||||
|
||||
it("reveals the Apps tab live when an app tool arrives via list-changed refresh", async () => {
|
||||
const plainTool: Tool = {
|
||||
name: "shell.exec",
|
||||
title: "Run Shell",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
const { rerender } = renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tools: [plainTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// Initially no app tools → no Apps tab.
|
||||
let radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Apps");
|
||||
|
||||
// A tools/list_changed refresh adds an app tool — the tab appears reactively.
|
||||
rerender(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tools: [plainTool, sampleAppTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(async () => {
|
||||
radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Apps");
|
||||
});
|
||||
});
|
||||
|
||||
it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tools: [sampleAppTool],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||
await user.click(tabSelect);
|
||||
await user.click(await screen.findByText("Apps"));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByDisplayValue("Apps")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// The app tool goes away (server switch / list-changed) — the Apps tab is
|
||||
// pulled from availableTabs and the activeTab fallback lands on Servers.
|
||||
rerender(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tools: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByDisplayValue("Apps")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByDisplayValue("Servers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Prompts tab when the server exposes no prompts", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
prompts: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Tools");
|
||||
expect(labels).not.toContain("Prompts");
|
||||
});
|
||||
|
||||
it("reveals the Prompts tab live when a prompt arrives via list-changed refresh", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
prompts: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
let radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Prompts");
|
||||
|
||||
rerender(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
prompts: [samplePrompt],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(async () => {
|
||||
radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Prompts");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Resources tab when the server exposes no resources or templates", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Tools");
|
||||
expect(labels).not.toContain("Resources");
|
||||
});
|
||||
|
||||
it("shows the Resources tab when the server exposes only resource templates", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
resources: [],
|
||||
resourceTemplates: [{ uriTemplate: "file:///{path}", name: "Files" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Resources");
|
||||
});
|
||||
|
||||
it("reveals the Resources tab live when a resource arrives via list-changed refresh", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
let radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).not.toContain(
|
||||
"Resources",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
resources: [sampleResource],
|
||||
resourceTemplates: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(async () => {
|
||||
radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Resources");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Tasks tab when the server has created no tasks", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tasks: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Tools");
|
||||
expect(labels).not.toContain("Tasks");
|
||||
});
|
||||
|
||||
it("shows the Tasks tab when at least one task exists", async () => {
|
||||
renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tasks: [sampleTask],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Tasks");
|
||||
});
|
||||
|
||||
it("reveals the Tasks tab live when a task is created", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tasks: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
let radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Tasks");
|
||||
|
||||
rerender(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
tasks: [sampleTask],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
await waitFor(async () => {
|
||||
radios = await screen.findAllByRole("radio");
|
||||
expect(radios.map((r) => r.getAttribute("value"))).toContain("Tasks");
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches onSetLogLevel through to the Logs screen", async () => {
|
||||
const onSetLogLevel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -625,6 +954,8 @@ describe("InspectorView", () => {
|
|||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
// An app tool is required for the Apps tab to be available (#1450).
|
||||
tools: [sampleAppTool],
|
||||
toolsListChanged: true,
|
||||
})}
|
||||
/>,
|
||||
|
|
@ -641,6 +972,8 @@ describe("InspectorView", () => {
|
|||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
// A prompt is required for the Prompts tab to be available (#1450).
|
||||
prompts: [samplePrompt],
|
||||
promptsListChanged: true,
|
||||
})}
|
||||
/>,
|
||||
|
|
@ -657,6 +990,8 @@ describe("InspectorView", () => {
|
|||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
// A resource is required for the Resources tab to be available (#1450).
|
||||
resources: [sampleResource],
|
||||
resourcesListChanged: true,
|
||||
})}
|
||||
/>,
|
||||
|
|
@ -673,6 +1008,8 @@ describe("InspectorView", () => {
|
|||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
// A prompt is required for the Prompts tab to be available (#1450).
|
||||
prompts: [samplePrompt],
|
||||
// Tools changed, but Prompts did not — the Prompts screen must
|
||||
// stay quiet.
|
||||
toolsListChanged: true,
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ const NETWORK_TAB = "Network";
|
|||
|
||||
const ALL_TABS: string[] = [
|
||||
SERVERS_TAB,
|
||||
"Tools",
|
||||
"Apps",
|
||||
"Tools",
|
||||
"Prompts",
|
||||
"Resources",
|
||||
"Tasks",
|
||||
|
|
@ -452,28 +452,6 @@ export function InspectorView({
|
|||
false,
|
||||
);
|
||||
|
||||
// Only show the non-Servers tabs when actually connected. Network is
|
||||
// additionally hidden for stdio servers — there is no HTTP traffic to
|
||||
// surface there, so the tab would always be empty. Capability-aware
|
||||
// tab gating (hide Tools when the server doesn't advertise `tools`, etc.)
|
||||
// can layer in later once the parent passes capabilities through.
|
||||
const availableTabs = useMemo<string[]>(() => {
|
||||
if (connectionStatus !== "connected") return [SERVERS_TAB];
|
||||
const active = serversInput.find((s) => s.id === activeServer);
|
||||
const isStdio = active ? getServerType(active.config) === "stdio" : false;
|
||||
return isStdio ? ALL_TABS.filter((t) => t !== NETWORK_TAB) : ALL_TABS;
|
||||
}, [connectionStatus, serversInput, activeServer]);
|
||||
|
||||
// Clamp the rendered tab to whatever's currently available. If the user
|
||||
// had "Tools" selected and the connection drops, `availableTabs` becomes
|
||||
// `[Servers]` and the view renders Servers without us having to imperatively
|
||||
// reset the state (and trip the `set-state-in-effect` lint). When the
|
||||
// connection comes back, the previous selection pops in again because
|
||||
// `selectedTab` is preserved.
|
||||
const activeTab = availableTabs.includes(selectedTab)
|
||||
? selectedTab
|
||||
: SERVERS_TAB;
|
||||
|
||||
const appTools = useMemo<Tool[]>(() => {
|
||||
return tools.filter((tool) => {
|
||||
try {
|
||||
|
|
@ -487,6 +465,57 @@ export function InspectorView({
|
|||
});
|
||||
}, [tools]);
|
||||
|
||||
// Only show the non-Servers tabs when actually connected. Network is
|
||||
// additionally hidden for stdio servers — there is no HTTP traffic to
|
||||
// surface there, so the tab would always be empty. Apps, Prompts,
|
||||
// Resources, and Tasks are content-gated (#1450): each is hidden unless its
|
||||
// list has at least one entry, so an empty screen is never reachable.
|
||||
// Resources is gated on resources OR templates, since a server may expose
|
||||
// only templates; Tasks appear once a task-augmented tool call creates one
|
||||
// (the "run as task" affordance lives on the Tools screen, gated by the
|
||||
// server's task support). These memo dependencies make the tabs
|
||||
// appear/disappear live as the lists change (list-changed refresh, server
|
||||
// switch) — when app tools exist but the sandbox is unavailable the Apps
|
||||
// tab stays visible so its "unavailable" message remains reachable. Users
|
||||
// who want to inspect a server's advertised capabilities regardless of
|
||||
// current contents can open the Connection Info modal.
|
||||
const availableTabs = useMemo<string[]>(() => {
|
||||
if (connectionStatus !== "connected") return [SERVERS_TAB];
|
||||
const active = serversInput.find((s) => s.id === activeServer);
|
||||
const isStdio = active ? getServerType(active.config) === "stdio" : false;
|
||||
const hasApps = appTools.length > 0;
|
||||
const hasPrompts = prompts.length > 0;
|
||||
const hasResources = resources.length > 0 || resourceTemplates.length > 0;
|
||||
const hasTasks = tasks.length > 0;
|
||||
return ALL_TABS.filter((t) => {
|
||||
if (t === NETWORK_TAB && isStdio) return false;
|
||||
if (t === "Apps" && !hasApps) return false;
|
||||
if (t === "Prompts" && !hasPrompts) return false;
|
||||
if (t === "Resources" && !hasResources) return false;
|
||||
if (t === "Tasks" && !hasTasks) return false;
|
||||
return true;
|
||||
});
|
||||
}, [
|
||||
connectionStatus,
|
||||
serversInput,
|
||||
activeServer,
|
||||
appTools,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
tasks,
|
||||
]);
|
||||
|
||||
// Clamp the rendered tab to whatever's currently available. If the user
|
||||
// had "Tools" selected and the connection drops, `availableTabs` becomes
|
||||
// `[Servers]` and the view renders Servers without us having to imperatively
|
||||
// reset the state (and trip the `set-state-in-effect` lint). When the
|
||||
// connection comes back, the previous selection pops in again because
|
||||
// `selectedTab` is preserved.
|
||||
const activeTab = availableTabs.includes(selectedTab)
|
||||
? selectedTab
|
||||
: SERVERS_TAB;
|
||||
|
||||
// Merge the parent's `serversInput` (static config) with the runtime
|
||||
// connection state owned by the parent — only the active server reflects
|
||||
// the live status; the rest render as `disconnected`. Handshake errors
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { Text } from "@mantine/core";
|
||||
|
||||
export const ThemeText = Text.extend({
|
||||
// `tabGlow` labels carry the `.tab-glow` class so a freshly-appeared tab can
|
||||
// pulse a red glow (the keyframe + `[data-glow="on"]` trigger live in
|
||||
// App.css). Auto-assigning the class keeps `className` out of the JSX (#1450).
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === "tabGlow") return { root: "tab-glow" };
|
||||
return {};
|
||||
},
|
||||
styles: (_theme, props) => {
|
||||
if (props.variant === "monoBreak") {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in a new issue