Merge pull request #1634 from modelcontextprotocol/1623-rename-history-to-protocol
refactor(web): rename "History" tab/feature to "Protocol" (#1623)
This commit is contained in:
commit
50e780ff71
52 changed files with 524 additions and 429 deletions
|
|
@ -309,10 +309,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
onClearCompletedTasks: () => void;
|
onClearCompletedTasks: () => void;
|
||||||
onRefreshTasks: () => void;
|
onRefreshTasks: () => void;
|
||||||
onServerSettings: (id: string) => void;
|
onServerSettings: (id: string) => void;
|
||||||
onClearHistory: () => void;
|
onClearProtocol: () => void;
|
||||||
onReplayHistory: (id: string) => void;
|
onReplayProtocol: (id: string) => void;
|
||||||
onTogglePinHistory: (id: string) => void;
|
onTogglePinProtocol: (id: string) => void;
|
||||||
pinnedHistoryIds?: Set<string>;
|
pinnedProtocolIds?: Set<string>;
|
||||||
}) => (
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="tool-status">
|
<span data-testid="tool-status">
|
||||||
|
|
@ -419,15 +419,15 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||||
<span data-testid="pinned-history">
|
<span data-testid="pinned-history">
|
||||||
{Array.from(props.pinnedHistoryIds ?? []).join(",")}
|
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => props.onTogglePinHistory("hist-1")}>
|
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
|
||||||
toggle-pin
|
toggle-pin
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onReplayHistory("hist-1")}>
|
<button onClick={() => props.onReplayProtocol("hist-1")}>
|
||||||
replay-history
|
replay-history
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onClearHistory()}>clear-history</button>
|
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ import {
|
||||||
EMPTY_APPS_UI,
|
EMPTY_APPS_UI,
|
||||||
EMPTY_TASKS_UI,
|
EMPTY_TASKS_UI,
|
||||||
EMPTY_LOGS_UI,
|
EMPTY_LOGS_UI,
|
||||||
EMPTY_HISTORY_UI,
|
EMPTY_PROTOCOL_UI,
|
||||||
EMPTY_NETWORK_UI,
|
EMPTY_NETWORK_UI,
|
||||||
EMPTY_CONSOLE_UI,
|
EMPTY_CONSOLE_UI,
|
||||||
} from "./components/screens/screenUiState";
|
} from "./components/screens/screenUiState";
|
||||||
|
|
@ -140,7 +140,7 @@ import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/Con
|
||||||
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||||
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
||||||
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
||||||
import { isReplayableHistoryMethod } from "./components/groups/historyUtils.js";
|
import { isReplayableProtocolMethod } from "./components/groups/protocolUtils.js";
|
||||||
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||||
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
||||||
import { StepUpAuthModal } from "./components/groups/StepUpAuthModal/StepUpAuthModal";
|
import { StepUpAuthModal } from "./components/groups/StepUpAuthModal/StepUpAuthModal";
|
||||||
|
|
@ -267,21 +267,21 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-issue the original request behind a History entry. The call goes through
|
// Re-issue the original request behind a Protocol entry. The call goes through
|
||||||
// InspectorClient → tracked transport → message log, so the replayed
|
// InspectorClient → tracked transport → message log, so the replayed
|
||||||
// request+response surface as a fresh History entry (history-local) — it
|
// request+response surface as a fresh Protocol entry (protocol-local) — it
|
||||||
// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a
|
// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a
|
||||||
// human-readable reason when the entry can't be replayed (unsupported method,
|
// human-readable reason when the entry can't be replayed (unsupported method,
|
||||||
// or a tool that's no longer present), or null on a dispatched replay.
|
// or a tool that's no longer present), or null on a dispatched replay.
|
||||||
async function replayHistoryRequest(
|
async function replayProtocolRequest(
|
||||||
client: InspectorClient,
|
client: InspectorClient,
|
||||||
method: string,
|
method: string,
|
||||||
params: Record<string, unknown> | undefined,
|
params: Record<string, unknown> | undefined,
|
||||||
tools: Tool[],
|
tools: Tool[],
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Gate on the shared replayable-method set (the same one HistoryEntry uses to
|
// Gate on the shared replayable-method set (the same one ProtocolEntry uses to
|
||||||
// show/hide the Replay button) so the two can't drift.
|
// show/hide the Replay button) so the two can't drift.
|
||||||
if (!isReplayableHistoryMethod(method)) {
|
if (!isReplayableProtocolMethod(method)) {
|
||||||
return `Replay isn't supported for "${method}".`;
|
return `Replay isn't supported for "${method}".`;
|
||||||
}
|
}
|
||||||
// Pagination cursor carried by the */list requests; replaying the same page
|
// Pagination cursor carried by the */list requests; replaying the same page
|
||||||
|
|
@ -755,11 +755,11 @@ function App() {
|
||||||
const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI);
|
const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI);
|
||||||
const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI);
|
const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI);
|
||||||
const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI);
|
const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI);
|
||||||
const [historyUi, setHistoryUi] = useState(EMPTY_HISTORY_UI);
|
const [protocolUi, setProtocolUi] = useState(EMPTY_PROTOCOL_UI);
|
||||||
// History entries the user pinned (by entry id). Session-scoped — the ids
|
// Protocol entries the user pinned (by entry id). Session-scoped — the ids
|
||||||
// reference message-log entries, which clear on disconnect, so this resets
|
// reference message-log entries, which clear on disconnect, so this resets
|
||||||
// with the rest of the per-screen state.
|
// with the rest of the per-screen state.
|
||||||
const [pinnedHistoryIds, setPinnedHistoryIds] = useState<Set<string>>(
|
const [pinnedProtocolIds, setPinnedProtocolIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI);
|
const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI);
|
||||||
|
|
@ -1014,8 +1014,8 @@ function App() {
|
||||||
setAppsUi(EMPTY_APPS_UI);
|
setAppsUi(EMPTY_APPS_UI);
|
||||||
setTasksUi(EMPTY_TASKS_UI);
|
setTasksUi(EMPTY_TASKS_UI);
|
||||||
setLogsUi(EMPTY_LOGS_UI);
|
setLogsUi(EMPTY_LOGS_UI);
|
||||||
setHistoryUi(EMPTY_HISTORY_UI);
|
setProtocolUi(EMPTY_PROTOCOL_UI);
|
||||||
setPinnedHistoryIds(new Set());
|
setPinnedProtocolIds(new Set());
|
||||||
setNetworkUi(EMPTY_NETWORK_UI);
|
setNetworkUi(EMPTY_NETWORK_UI);
|
||||||
// Only the search filter resets here; the stderr entries themselves live in
|
// Only the search filter resets here; the stderr entries themselves live in
|
||||||
// StderrLogState, which deliberately survives connect/disconnect so a failed
|
// StderrLogState, which deliberately survives connect/disconnect so a failed
|
||||||
|
|
@ -1059,7 +1059,7 @@ function App() {
|
||||||
// Surface incoming `notifications/progress` as toasts so the user can watch a
|
// Surface incoming `notifications/progress` as toasts so the user can watch a
|
||||||
// long-running tool's progress while staying on the tool view — the v2
|
// long-running tool's progress while staying on the tool view — the v2
|
||||||
// replacement for v1's always-visible "Server Notifications" shelf (#1414).
|
// replacement for v1's always-visible "Server Notifications" shelf (#1414).
|
||||||
// The full notification history still lives in the History tab; these toasts
|
// The full notification history still lives in the Protocol tab; these toasts
|
||||||
// are the at-a-glance, in-context signal. Toasts are keyed by progress stream
|
// are the at-a-glance, in-context signal. Toasts are keyed by progress stream
|
||||||
// (see `progressToastId`) and replaced per tick so a chatty server updates one
|
// (see `progressToastId`) and replaced per tick so a chatty server updates one
|
||||||
// toast rather than stacking one per tick.
|
// toast rather than stacking one per tick.
|
||||||
|
|
@ -1171,7 +1171,7 @@ function App() {
|
||||||
// with `requestorTaskUpdated` so we skip it to avoid double-firing. One toast per
|
// with `requestorTaskUpdated` so we skip it to avoid double-firing. One toast per
|
||||||
// taskId, replaced per tick, dismissed on terminal status (which also prunes the
|
// taskId, replaced per tick, dismissed on terminal status (which also prunes the
|
||||||
// task's progress entry) and on client teardown. The full status history still
|
// task's progress entry) and on client teardown. The full status history still
|
||||||
// lives in the History view.
|
// lives in the Protocol view.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
const liveToastIds = taskToastIdsRef.current;
|
const liveToastIds = taskToastIdsRef.current;
|
||||||
|
|
@ -1560,7 +1560,7 @@ function App() {
|
||||||
appsUi,
|
appsUi,
|
||||||
tasksUi,
|
tasksUi,
|
||||||
logsUi,
|
logsUi,
|
||||||
historyUi,
|
protocolUi,
|
||||||
networkUi,
|
networkUi,
|
||||||
}),
|
}),
|
||||||
...(remoteSessionId && { remoteSessionId }),
|
...(remoteSessionId && { remoteSessionId }),
|
||||||
|
|
@ -1578,7 +1578,7 @@ function App() {
|
||||||
appsUi,
|
appsUi,
|
||||||
tasksUi,
|
tasksUi,
|
||||||
logsUi,
|
logsUi,
|
||||||
historyUi,
|
protocolUi,
|
||||||
networkUi,
|
networkUi,
|
||||||
onBeforeOAuthRedirect,
|
onBeforeOAuthRedirect,
|
||||||
],
|
],
|
||||||
|
|
@ -2189,7 +2189,7 @@ function App() {
|
||||||
setAppsUi,
|
setAppsUi,
|
||||||
setTasksUi,
|
setTasksUi,
|
||||||
setLogsUi,
|
setLogsUi,
|
||||||
setHistoryUi,
|
setProtocolUi,
|
||||||
setNetworkUi,
|
setNetworkUi,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
clearToolCallState: () => setToolCallState(undefined),
|
clearToolCallState: () => setToolCallState(undefined),
|
||||||
|
|
@ -2987,7 +2987,7 @@ function App() {
|
||||||
const onClearLogs = useCallback(() => {
|
const onClearLogs = useCallback(() => {
|
||||||
if (!messageLogState) return;
|
if (!messageLogState) return;
|
||||||
// Clear only the log notifications, not the entire request/response
|
// Clear only the log notifications, not the entire request/response
|
||||||
// history (which the History screen renders from the same source).
|
// history (which the Protocol screen renders from the same source).
|
||||||
messageLogState.clearMessages(
|
messageLogState.clearMessages(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.direction === "notification" &&
|
m.direction === "notification" &&
|
||||||
|
|
@ -2999,10 +2999,10 @@ function App() {
|
||||||
// Panel-level Clear clears the (unpinned) history and keeps pinned entries —
|
// Panel-level Clear clears the (unpinned) history and keeps pinned entries —
|
||||||
// pinning is the way to protect an entry from Clear. This matches the button's
|
// pinning is the way to protect an entry from Clear. This matches the button's
|
||||||
// `disabled={unpinnedEntries.length === 0}` gating and the per-section model,
|
// `disabled={unpinnedEntries.length === 0}` gating and the per-section model,
|
||||||
// and leaves pinnedHistoryIds valid (the pins it references still exist).
|
// and leaves pinnedProtocolIds valid (the pins it references still exist).
|
||||||
const onClearHistory = useCallback(() => {
|
const onClearProtocol = useCallback(() => {
|
||||||
messageLogState?.clearMessages((m) => !pinnedHistoryIds.has(m.id));
|
messageLogState?.clearMessages((m) => !pinnedProtocolIds.has(m.id));
|
||||||
}, [messageLogState, pinnedHistoryIds]);
|
}, [messageLogState, pinnedProtocolIds]);
|
||||||
|
|
||||||
const onClearNetwork = useCallback(() => {
|
const onClearNetwork = useCallback(() => {
|
||||||
fetchRequestLogState?.clearFetchRequests();
|
fetchRequestLogState?.clearFetchRequests();
|
||||||
|
|
@ -3016,50 +3016,50 @@ function App() {
|
||||||
);
|
);
|
||||||
}, [fetchRequests, activeServerId]);
|
}, [fetchRequests, activeServerId]);
|
||||||
|
|
||||||
const onExportHistory = useCallback(() => {
|
const onExportProtocol = useCallback(() => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
downloadJsonFile(
|
downloadJsonFile(
|
||||||
buildExportFilename("history", activeServerId),
|
buildExportFilename("protocol", activeServerId),
|
||||||
JSON.stringify(messages, null, 2),
|
JSON.stringify(messages, null, 2),
|
||||||
);
|
);
|
||||||
}, [messages, activeServerId]);
|
}, [messages, activeServerId]);
|
||||||
|
|
||||||
// Clear just one section: remove its entries from the log by pin membership.
|
// Clear just one section: remove its entries from the log by pin membership.
|
||||||
// Clearing the pinned section also drops the (now-stale) pinned id set.
|
// Clearing the pinned section also drops the (now-stale) pinned id set.
|
||||||
const onClearHistorySection = useCallback(
|
const onClearProtocolSection = useCallback(
|
||||||
(section: "pinned" | "history") => {
|
(section: "pinned" | "history") => {
|
||||||
const isPinned = section === "pinned";
|
const isPinned = section === "pinned";
|
||||||
messageLogState?.clearMessages((m) =>
|
messageLogState?.clearMessages((m) =>
|
||||||
isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id),
|
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
|
||||||
);
|
);
|
||||||
if (isPinned) setPinnedHistoryIds(new Set());
|
if (isPinned) setPinnedProtocolIds(new Set());
|
||||||
},
|
},
|
||||||
[messageLogState, pinnedHistoryIds],
|
[messageLogState, pinnedProtocolIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export just one section's entries (by pin membership) to a JSON file.
|
// Export just one section's entries (by pin membership) to a JSON file.
|
||||||
const onExportHistorySection = useCallback(
|
const onExportProtocolSection = useCallback(
|
||||||
(section: "pinned" | "history") => {
|
(section: "pinned" | "history") => {
|
||||||
const isPinned = section === "pinned";
|
const isPinned = section === "pinned";
|
||||||
const subset = messages.filter((m) =>
|
const subset = messages.filter((m) =>
|
||||||
isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id),
|
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
|
||||||
);
|
);
|
||||||
if (subset.length === 0) return;
|
if (subset.length === 0) return;
|
||||||
downloadJsonFile(
|
downloadJsonFile(
|
||||||
buildExportFilename(
|
buildExportFilename(
|
||||||
isPinned ? "history-pinned" : "history-unpinned",
|
isPinned ? "protocol-pinned" : "protocol-unpinned",
|
||||||
activeServerId,
|
activeServerId,
|
||||||
),
|
),
|
||||||
JSON.stringify(subset, null, 2),
|
JSON.stringify(subset, null, 2),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[messages, pinnedHistoryIds, activeServerId],
|
[messages, pinnedProtocolIds, activeServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pin/unpin a history entry by id. HistoryListPanel sorts pinned entries to
|
// Pin/unpin a Protocol entry by id. ProtocolListPanel sorts pinned entries to
|
||||||
// the top; the set is session-scoped (see resetSessionScopedUiState).
|
// the top; the set is session-scoped (see resetSessionScopedUiState).
|
||||||
const onTogglePinHistory = useCallback((id: string) => {
|
const onTogglePinProtocol = useCallback((id: string) => {
|
||||||
setPinnedHistoryIds((prev) => {
|
setPinnedProtocolIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
|
|
@ -3070,12 +3070,12 @@ function App() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Replay a history entry: re-issue its original request so the fresh
|
// Replay a Protocol entry: re-issue its original request so the fresh
|
||||||
// request+response appear as a new History entry (history-local). A reason
|
// request+response appear as a new Protocol entry (protocol-local). A reason
|
||||||
// string (unsupported method / missing tool) surfaces as a toast; a genuine
|
// string (unsupported method / missing tool) surfaces as a toast; a genuine
|
||||||
// call error already shows up as the replayed entry's Error status, so only a
|
// call error already shows up as the replayed entry's Error status, so only a
|
||||||
// pre-flight failure (nothing logged) needs the fallback toast.
|
// pre-flight failure (nothing logged) needs the fallback toast.
|
||||||
const onReplayHistory = useCallback(
|
const onReplayProtocol = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
const entry = messages.find((m) => m.id === id);
|
const entry = messages.find((m) => m.id === id);
|
||||||
|
|
@ -3085,7 +3085,7 @@ function App() {
|
||||||
"params" in entry.message
|
"params" in entry.message
|
||||||
? (entry.message.params as Record<string, unknown> | undefined)
|
? (entry.message.params as Record<string, unknown> | undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
void replayHistoryRequest(inspectorClient, method, params, tools)
|
void replayProtocolRequest(inspectorClient, method, params, tools)
|
||||||
.then((reason) => {
|
.then((reason) => {
|
||||||
if (reason) {
|
if (reason) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|
@ -3687,7 +3687,7 @@ function App() {
|
||||||
logs={logs}
|
logs={logs}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
progressByTaskId={progressByTaskId}
|
progressByTaskId={progressByTaskId}
|
||||||
history={messages}
|
protocol={messages}
|
||||||
network={fetchRequests}
|
network={fetchRequests}
|
||||||
stderrLogs={stderrLogs}
|
stderrLogs={stderrLogs}
|
||||||
toolCallState={toolCallState}
|
toolCallState={toolCallState}
|
||||||
|
|
@ -3699,7 +3699,7 @@ function App() {
|
||||||
appsUi={appsUi}
|
appsUi={appsUi}
|
||||||
tasksUi={tasksUi}
|
tasksUi={tasksUi}
|
||||||
logsUi={logsUi}
|
logsUi={logsUi}
|
||||||
historyUi={historyUi}
|
protocolUi={protocolUi}
|
||||||
networkUi={networkUi}
|
networkUi={networkUi}
|
||||||
consoleUi={consoleUi}
|
consoleUi={consoleUi}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|
@ -3792,14 +3792,14 @@ function App() {
|
||||||
onLogsUiChange={setLogsUi}
|
onLogsUiChange={setLogsUi}
|
||||||
onClearLogs={onClearLogs}
|
onClearLogs={onClearLogs}
|
||||||
onExportLogs={onExportLogs}
|
onExportLogs={onExportLogs}
|
||||||
onHistoryUiChange={setHistoryUi}
|
onProtocolUiChange={setProtocolUi}
|
||||||
onClearHistory={onClearHistory}
|
onClearProtocol={onClearProtocol}
|
||||||
onExportHistory={onExportHistory}
|
onExportProtocol={onExportProtocol}
|
||||||
onClearHistorySection={onClearHistorySection}
|
onClearProtocolSection={onClearProtocolSection}
|
||||||
onExportHistorySection={onExportHistorySection}
|
onExportProtocolSection={onExportProtocolSection}
|
||||||
onReplayHistory={onReplayHistory}
|
onReplayProtocol={onReplayProtocol}
|
||||||
onTogglePinHistory={onTogglePinHistory}
|
onTogglePinProtocol={onTogglePinProtocol}
|
||||||
pinnedHistoryIds={pinnedHistoryIds}
|
pinnedProtocolIds={pinnedProtocolIds}
|
||||||
onNetworkUiChange={setNetworkUi}
|
onNetworkUiChange={setNetworkUi}
|
||||||
onClearNetwork={onClearNetwork}
|
onClearNetwork={onClearNetwork}
|
||||||
onExportNetwork={onExportNetwork}
|
onExportNetwork={onExportNetwork}
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,27 @@ describe("EmbeddableScrollArea", () => {
|
||||||
);
|
);
|
||||||
expect(ref.current).toBeInstanceOf(HTMLElement);
|
expect(ref.current).toBeInstanceOf(HTMLElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"constrains the content width when asked (embedded=%s)",
|
||||||
|
(embedded) => {
|
||||||
|
const ref = createRef<HTMLDivElement>();
|
||||||
|
renderWithMantine(
|
||||||
|
<EmbeddableScrollArea
|
||||||
|
embedded={embedded}
|
||||||
|
viewportRef={ref}
|
||||||
|
constrainContentWidth
|
||||||
|
>
|
||||||
|
<div>constrained content</div>
|
||||||
|
</EmbeddableScrollArea>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("constrained content")).toBeInTheDocument();
|
||||||
|
// The Mantine ScrollArea `content` slot has its default
|
||||||
|
// `min-width: max-content` relaxed to 0 so a long non-wrapping row
|
||||||
|
// (e.g. a network URL) can't stretch the list past its viewport (#1623).
|
||||||
|
const content = document.querySelector(".mantine-ScrollArea-content");
|
||||||
|
expect(content).not.toBeNull();
|
||||||
|
expect((content as HTMLElement).style.minWidth).toBe("0");
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,25 @@ export interface EmbeddableScrollAreaProps {
|
||||||
embedded: boolean;
|
embedded: boolean;
|
||||||
viewportRef: Ref<HTMLDivElement>;
|
viewportRef: Ref<HTMLDivElement>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Constrain the scrolled content to the viewport width instead of letting it
|
||||||
|
* grow to its own `max-content`. Mantine's ScrollArea `content` slot defaults
|
||||||
|
* to `min-width: max-content`, so a row with non-wrapping content (e.g. a long
|
||||||
|
* network URL) stretches every card past the column and it bleeds out (#1623).
|
||||||
|
* When true, the content can shrink to the viewport and each row must manage
|
||||||
|
* its own overflow (the Network URL scrolls inside its own inner ScrollArea).
|
||||||
|
* Left off for panels whose rows already wrap/truncate (Logs, Protocol), where
|
||||||
|
* the default lets a long line scroll the list horizontally instead.
|
||||||
|
*/
|
||||||
|
constrainContentWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relax the `content` slot's default `min-width: max-content` so the list can't
|
||||||
|
// grow wider than its viewport; see `constrainContentWidth`.
|
||||||
|
const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll region shared by the Logs / History / Network stream panels, which
|
* The scroll region shared by the Logs / Protocol / Network stream panels, which
|
||||||
* render both full-size (their own tab) and embedded (the monitoring column).
|
* render both full-size (their own tab) and embedded (the monitoring column).
|
||||||
* Centralizes the one layout difference between those two hosts.
|
* Centralizes the one layout difference between those two hosts.
|
||||||
*/
|
*/
|
||||||
|
|
@ -28,7 +43,9 @@ export function EmbeddableScrollArea({
|
||||||
embedded,
|
embedded,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
children,
|
children,
|
||||||
|
constrainContentWidth = false,
|
||||||
}: EmbeddableScrollAreaProps) {
|
}: EmbeddableScrollAreaProps) {
|
||||||
|
const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined;
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return (
|
return (
|
||||||
<Stack flex={1} mih={0} gap={0}>
|
<Stack flex={1} mih={0} gap={0}>
|
||||||
|
|
@ -37,6 +54,7 @@ export function EmbeddableScrollArea({
|
||||||
mah="100%"
|
mah="100%"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
offsetScrollbars
|
offsetScrollbars
|
||||||
|
styles={styles}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
|
|
@ -49,6 +67,7 @@ export function EmbeddableScrollArea({
|
||||||
mah={FULLSIZE_MAH}
|
mah={FULLSIZE_MAH}
|
||||||
type="scroll"
|
type="scroll"
|
||||||
offsetScrollbars
|
offsetScrollbars
|
||||||
|
styles={styles}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export interface ExpandToggleProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon toggle for a per-entry expand/collapse control (History, Network, and
|
* Icon toggle for a per-entry expand/collapse control (Protocol, Network, and
|
||||||
* Task cards). Uses the same expand/collapse-vertical icons as the list-level
|
* Task cards). Uses the same expand/collapse-vertical icons as the list-level
|
||||||
* ListToggle: collapsed shows the expand icon, expanded shows the collapse
|
* ListToggle: collapsed shows the expand icon, expanded shows the collapse
|
||||||
* icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the
|
* icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const ToggleLabel = Text.withProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single full-width filter toggle used by the Logging, History, and Network
|
* A single full-width filter toggle used by the Logging, Protocol, and Network
|
||||||
* controls. The `filterToggle` theme variant + `.filter-toggle` rules own the
|
* controls. The `filterToggle` theme variant + `.filter-toggle` rules own the
|
||||||
* styling: hover shows a thin border, the active (`aria-pressed`) state shows a
|
* styling: hover shows a thin border, the active (`aria-pressed`) state shows a
|
||||||
* filled background. Keeping hover as a border (not a fill) means toggling a
|
* filled background. Keeping hover as a border (not a fill) means toggling a
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@ export const Incoming: Story = {
|
||||||
args: { direction: "incoming" },
|
args: { direction: "incoming" },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.getByText("client ← server")).toBeInTheDocument();
|
expect(canvas.getByText("server → client")).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ describe("MessageDirectionBadge", () => {
|
||||||
expect(screen.getByText("client → server")).toBeInTheDocument();
|
expect(screen.getByText("client → server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders client ← server for incoming", () => {
|
it("renders server → client for incoming", () => {
|
||||||
renderWithMantine(<MessageDirectionBadge direction="incoming" />);
|
renderWithMantine(<MessageDirectionBadge direction="incoming" />);
|
||||||
expect(screen.getByText("client ← server")).toBeInTheDocument();
|
expect(screen.getByText("server → client")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,21 @@ export interface MessageDirectionBadgeProps {
|
||||||
/**
|
/**
|
||||||
* Direction of travel for the entry: "outgoing" = the inspector sent it to
|
* Direction of travel for the entry: "outgoing" = the inspector sent it to
|
||||||
* the server (client → server); "incoming" = the server sent it to the
|
* the server (client → server); "incoming" = the server sent it to the
|
||||||
* inspector (client ← server).
|
* inspector (server → client).
|
||||||
*/
|
*/
|
||||||
direction: "outgoing" | "incoming";
|
direction: "outgoing" | "incoming";
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL: Record<MessageDirectionBadgeProps["direction"], string> = {
|
const LABEL: Record<MessageDirectionBadgeProps["direction"], string> = {
|
||||||
outgoing: "client → server",
|
outgoing: "client → server",
|
||||||
incoming: "client ← server",
|
incoming: "server → client",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dual-state badge showing which way a History/Network entry traveled. Outgoing
|
* Dual-state badge showing which way a Protocol/Network entry traveled. Outgoing
|
||||||
* (client → server) is green; incoming (client ← server) is purple — not yellow,
|
* (client → server) is green; incoming (server → client) is purple — not yellow,
|
||||||
* which (paired with green) reads as caution/ok status rather than direction.
|
* which (paired with green) reads as caution/ok status rather than direction.
|
||||||
* Shared by `HistoryEntry` and `NetworkEntry`.
|
* Shared by `ProtocolEntry` and `NetworkEntry`.
|
||||||
*/
|
*/
|
||||||
export function MessageDirectionBadge({
|
export function MessageDirectionBadge({
|
||||||
direction,
|
direction,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export interface PinColumnButtonProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toolbar button that pins the owning monitor screen (Logs / History / Network)
|
* Toolbar button that pins the owning monitor screen (Logs / Protocol / Network)
|
||||||
* into the resizable column on the right of the InspectorView (#1616). Distinct
|
* into the resizable column on the right of the InspectorView (#1616). Distinct
|
||||||
* from `PinToggle` (which pins individual history entries) — this one opens a
|
* from `PinToggle` (which pins individual history entries) — this one opens a
|
||||||
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled
|
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export interface ReplayButtonProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon form of the "Replay" action, used in the compact (column) HistoryEntry
|
* Icon form of the "Replay" action, used in the compact (column) ProtocolEntry
|
||||||
* layout where the text button is replaced by a replay icon sitting next to the
|
* layout where the text button is replaced by a replay icon sitting next to the
|
||||||
* pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling.
|
* pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ describe("MessageDirectionFilter", () => {
|
||||||
screen.getByRole("button", { name: "client → server" }),
|
screen.getByRole("button", { name: "client → server" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "client ← server" }),
|
screen.getByRole("button", { name: "server → client" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ describe("MessageDirectionFilter", () => {
|
||||||
onToggleDirection={onToggleDirection}
|
onToggleDirection={onToggleDirection}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "client ← server" }));
|
await user.click(screen.getByRole("button", { name: "server → client" }));
|
||||||
expect(onToggleDirection).toHaveBeenCalledWith("server", false);
|
expect(onToggleDirection).toHaveBeenCalledWith("server", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ const SubtleButton = Button.withProps({
|
||||||
|
|
||||||
// The two message directions, in display order. Label + color mirror the
|
// The two message directions, in display order. Label + color mirror the
|
||||||
// MessageDirectionBadge: outgoing (client → server) is green, incoming
|
// MessageDirectionBadge: outgoing (client → server) is green, incoming
|
||||||
// (client ← server) is violet.
|
// (server → client) is violet.
|
||||||
const MESSAGE_DIRECTIONS: {
|
const MESSAGE_DIRECTIONS: {
|
||||||
origin: MessageOrigin;
|
origin: MessageOrigin;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ origin: "client", label: "client → server", color: "green" },
|
{ origin: "client", label: "client → server", color: "green" },
|
||||||
{ origin: "server", label: "client ← server", color: "violet" },
|
{ origin: "server", label: "server → client", color: "violet" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface MessageDirectionFilterProps {
|
export interface MessageDirectionFilterProps {
|
||||||
|
|
@ -27,8 +27,8 @@ export interface MessageDirectionFilterProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Filter by Message Direction" section — a Select/Deselect All control plus a
|
* "Filter by Message Direction" section — a Select/Deselect All control plus a
|
||||||
* FilterToggleButton per direction (client → server / client ← server). Used by
|
* FilterToggleButton per direction (client → server / server → client). Used by
|
||||||
* the History controls. (Kept as its own component so the section is testable in
|
* the Protocol controls. (Kept as its own component so the section is testable in
|
||||||
* isolation and reusable if another screen ever needs a direction filter.)
|
* isolation and reusable if another screen ever needs a direction filter.)
|
||||||
*/
|
*/
|
||||||
export function MessageDirectionFilter({
|
export function MessageDirectionFilter({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const meta: Meta<typeof MonitoringControls> = {
|
||||||
title: "Groups/MonitoringControls",
|
title: "Groups/MonitoringControls",
|
||||||
component: MonitoringControls,
|
component: MonitoringControls,
|
||||||
args: {
|
args: {
|
||||||
tabs: ["Logs", "History", "Network"],
|
tabs: ["Logs", "Protocol", "Network"],
|
||||||
value: "Logs",
|
value: "Logs",
|
||||||
onChange: fn(),
|
onChange: fn(),
|
||||||
searchValue: "",
|
searchValue: "",
|
||||||
|
|
@ -29,7 +29,7 @@ export const AllTabs: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StdioServer: Story = {
|
export const StdioServer: Story = {
|
||||||
args: { tabs: ["Logs", "History"], value: "History" },
|
args: { tabs: ["Logs", "Protocol"], value: "Protocol" },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
|
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
type MonitoringControlsProps,
|
type MonitoringControlsProps,
|
||||||
} from "./MonitoringControls";
|
} from "./MonitoringControls";
|
||||||
|
|
||||||
const TABS = ["Logs", "History", "Network"];
|
const TABS = ["Logs", "Protocol", "Network"];
|
||||||
|
|
||||||
function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
|
function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
|
||||||
const props: MonitoringControlsProps = {
|
const props: MonitoringControlsProps = {
|
||||||
|
|
@ -31,7 +31,7 @@ describe("MonitoringControls", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders only the tabs it is given", () => {
|
it("renders only the tabs it is given", () => {
|
||||||
renderControls({ tabs: ["Logs", "History"] });
|
renderControls({ tabs: ["Logs", "Protocol"] });
|
||||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
||||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -53,8 +53,8 @@ describe("MonitoringControls", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the active tab as selected", () => {
|
it("marks the active tab as selected", () => {
|
||||||
renderControls({ value: "History" });
|
renderControls({ value: "Protocol" });
|
||||||
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
|
expect(screen.getByRole("radio", { name: "Protocol" })).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onChange when a different tab is chosen", async () => {
|
it("calls onChange when a different tab is chosen", async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
|
||||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||||
|
|
||||||
export interface MonitoringControlsProps {
|
export interface MonitoringControlsProps {
|
||||||
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
|
/** The monitor screens currently available to pin (e.g. Logs/Protocol/Network). */
|
||||||
tabs: string[];
|
tabs: string[];
|
||||||
/** The active monitor tab shown in the column below. */
|
/** The active monitor tab shown in the column below. */
|
||||||
value: string;
|
value: string;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const demoScreens = {
|
||||||
<Text>Log stream</Text>
|
<Text>Log stream</Text>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
History: (
|
Protocol: (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Text>Request history</Text>
|
<Text>Request history</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -32,7 +32,7 @@ const meta: Meta<typeof MonitoringScreen> = {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
args: {
|
args: {
|
||||||
tabs: ["Logs", "History", "Network"],
|
tabs: ["Logs", "Protocol", "Network"],
|
||||||
value: "Logs",
|
value: "Logs",
|
||||||
onChange: fn(),
|
onChange: fn(),
|
||||||
searchValue: "",
|
searchValue: "",
|
||||||
|
|
@ -52,8 +52,8 @@ export const Logs: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const History: Story = {
|
export const Protocol: Story = {
|
||||||
args: { value: "History" },
|
args: { value: "Protocol" },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.getByText("Request history")).toBeInTheDocument();
|
expect(canvas.getByText("Request history")).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import {
|
||||||
type MonitoringScreenProps,
|
type MonitoringScreenProps,
|
||||||
} from "./MonitoringScreen";
|
} from "./MonitoringScreen";
|
||||||
|
|
||||||
const TABS = ["Logs", "History", "Network"];
|
const TABS = ["Logs", "Protocol", "Network"];
|
||||||
|
|
||||||
function screens() {
|
function screens() {
|
||||||
return {
|
return {
|
||||||
Logs: <div>logs-body</div>,
|
Logs: <div>logs-body</div>,
|
||||||
History: <div>history-body</div>,
|
Protocol: <div>history-body</div>,
|
||||||
Network: <div>network-body</div>,
|
Network: <div>network-body</div>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ function renderScreen(overrides: Partial<MonitoringScreenProps> = {}) {
|
||||||
|
|
||||||
describe("MonitoringScreen", () => {
|
describe("MonitoringScreen", () => {
|
||||||
it("renders the screen for the active tab", () => {
|
it("renders the screen for the active tab", () => {
|
||||||
renderScreen({ value: "History" });
|
renderScreen({ value: "Protocol" });
|
||||||
expect(screen.getByText("history-body")).toBeInTheDocument();
|
expect(screen.getByText("history-body")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("logs-body")).toBeNull();
|
expect(screen.queryByText("logs-body")).toBeNull();
|
||||||
expect(screen.queryByText("network-body")).toBeNull();
|
expect(screen.queryByText("network-body")).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Divider, Stack } from "@mantine/core";
|
||||||
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
||||||
|
|
||||||
export interface MonitoringScreenProps {
|
export interface MonitoringScreenProps {
|
||||||
/** Available monitor tabs (Logs/History/Network, filtered by capability). */
|
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
|
||||||
tabs: string[];
|
tabs: string[];
|
||||||
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
||||||
value: string;
|
value: string;
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const UrlScroll = Text.withProps({
|
||||||
variant: "nowrap",
|
variant: "nowrap",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Left / right clusters for a compact header line (mirrors HistoryEntry).
|
// Left / right clusters for a compact header line (mirrors ProtocolEntry).
|
||||||
const HeaderCluster = Group.withProps({
|
const HeaderCluster = Group.withProps({
|
||||||
gap: "sm",
|
gap: "sm",
|
||||||
wrap: "nowrap",
|
wrap: "nowrap",
|
||||||
|
|
@ -240,7 +240,7 @@ export function NetworkEntry({
|
||||||
// The list-level Expand/Collapse toggle is authoritative: each time the
|
// The list-level Expand/Collapse toggle is authoritative: each time the
|
||||||
// parent changes `isListExpanded`, every entry snaps to that state and
|
// parent changes `isListExpanded`, every entry snaps to that state and
|
||||||
// any per-entry override is intentionally discarded. Mirrors
|
// any per-entry override is intentionally discarded. Mirrors
|
||||||
// HistoryEntry; do not change without aligning both. This re-runs on
|
// ProtocolEntry; do not change without aligning both. This re-runs on
|
||||||
// re-render when `isListExpanded` keeps its reference, but the setter
|
// re-render when `isListExpanded` keeps its reference, but the setter
|
||||||
// is a no-op when the next value equals the current one.
|
// is a no-op when the next value equals the current one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue