vercel/next.js · #97796
Improve Request Insights context menus and trace interactions
packages/next/src/next-devtools/dev-overlay/components/request-insights/request-insights-panel.css100 + / 0 −
@@ -287,6 +287,89 @@ background: var(--color-gray-1000); } +.request-insights-copy-error {+ flex-shrink: 0;+ padding: 6px 10px;+ border-bottom: 1px solid var(--color-gray-400);+ color: var(--color-red-900);+ font-size: var(--size-12);+}++.request-insights-context-positioner {+ z-index: var(--top-z-index);+}++.request-insights-context-backdrop {+ position: fixed;+ z-index: var(--top-z-index);+ inset: 0;+}++.request-insights-context-menu {+ overflow: hidden;+ width: min(260px, calc(100vw - 24px));+ border: 1px solid var(--color-gray-400);+ border-radius: 8px;+ background: var(--color-background-100);+ box-shadow: 0px 8px 24px rgb(0 0 0 / 16%);+ padding: 4px;+ color: var(--color-gray-1000);+ user-select: none;+}++.request-insights-context-label {+ padding: 6px 8px 3px;+ color: var(--color-gray-800);+ font-size: var(--size-10);+ font-weight: 600;+ letter-spacing: 0.04em;+ text-transform: uppercase;+}++.request-insights-context-preview {+ display: grid;+ gap: 3px;+ padding: 4px 8px 8px;+ color: var(--color-gray-800);+ font-size: var(--size-11);+}++.request-insights-context-preview strong {+ overflow: hidden;+ color: var(--color-gray-1000);+ font-size: var(--size-12);+ text-overflow: ellipsis;+ white-space: nowrap;+}++.request-insights-context-preview code {+ display: block;+ margin-top: 2px;+ font-family: var(--font-mono);+ font-size: var(--size-10);+ overflow-wrap: anywhere;+}++.request-insights-context-separator {+ height: 1px;+ margin: 3px 4px;+ background: var(--color-gray-400);+}++.request-insights-context-item {+ display: flex;+ min-height: 28px;+ align-items: center;+ border-radius: 6px;+ padding: 5px 8px;+ cursor: pointer;+ font-size: var(--size-12);+}++.request-insights-context-item[data-highlighted] {+ background: var(--color-gray-200);+}+ .request-insights-row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto;@@ -587,6 +670,11 @@ white-space: normal; } +.request-insights-trace-tooltip {+ text-align: left;+ white-space: pre-line;+}+ .request-insights-error { padding: 8px 16px; border-bottom: 1px solid var(--color-gray-400);@@ -737,6 +825,18 @@ font-size: var(--size-12); } +.request-insights-span-name[data-react-source-link] {+ cursor: pointer;+}++.request-insights-span-name[data-react-source-link]:hover+ .request-insights-span-label+ > span:last-child {+ color: var(--color-gray-1000);+ text-decoration: underline;+ text-underline-offset: 3px;+}+ .request-insights-span-label { display: flex; align-items: center;packages/next/src/next-devtools/dev-overlay/components/request-insights/request-insights-panel.tsx343 + / 80 −
@@ -1,13 +1,17 @@ import {+ createContext,+ useContext, useCallback, useEffect, useId, useMemo, useRef, useState,+ type ReactElement, type KeyboardEvent, type PointerEvent, } from 'react'+import { ContextMenu } from '@base-ui-components/react/context-menu' import { Menu } from '@base-ui-components/react/menu' import { getRequestInsightKey,@@ -17,6 +21,7 @@ import { } from '../../../shared/request-insights' import { getRequestInsightFetchCount,+ getRequestInsightSpanCount, isRequestInsightSummary, type RequestInsightListItem, } from '../../../shared/request-insights-summary'@@ -37,6 +42,8 @@ import { import { formatRequestRouteParams, getRequestDisplayUrl,+ getRequestInsightAgentPrompt,+ getRequestInsightSpanAgentPrompt, getRequestListDisplayUrl, getRequestRouteParams, } from './request-label'@@ -63,6 +70,8 @@ import { } from './request-insights-history' import './request-insights-panel.css' +const CopyErrorContext = createContext<(error: string | null) => void>(() => {})+ const TRACE_TICK_COUNT = 5 const REQUEST_ROW_HEIGHT = 52 const REQUEST_ROW_OVERSCAN = 8@@ -71,6 +80,7 @@ const REQUEST_SCROLLBAR_MIN_THUMB_HEIGHT = 24 export function RequestInsightsPanel() { const { dispatch, state, shadowRoot } = useDevOverlayContext()+ const [copyError, setCopyError] = useState<string | null>(null) const [activeFilters, setActiveFilters] = useState< readonly RequestInsightFilter[] >([])@@ -115,6 +125,9 @@ export function RequestInsightsPanel() { const [selectedRequestKey, setSelectedRequestKey] = useState<string | null>( () => getActiveRequestKey(visibleRequests, null) )+ const [contextMenuRequestKey, setContextMenuRequestKey] = useState<+ string | null+ >(null) const activeRequestKey = getActiveRequestKey( visibleRequests, selectedRequestKey@@ -180,7 +193,7 @@ export function RequestInsightsPanel() { ) } - return (+ const content = ( <div className="request-insights-panel"> <div className="request-insights-list"> <div className="request-insights-list-toolbar">@@ -289,6 +302,11 @@ export function RequestInsightsPanel() { </div> </div> </div>+ {copyError ? (+ <div className="request-insights-copy-error" role="alert">+ {copyError}+ </div>+ ) : null} {activeFilters.length > 0 ? ( <div className="request-insights-filter-status"> <span>@@ -336,12 +354,22 @@ export function RequestInsightsPanel() { ) : ( <VirtualRequestList activeRequestKey={activeRequestKey}+ contextMenuRequestKey={contextMenuRequestKey} entries={listEntries} hasMore={!isPaused && history.hasMore} initialRequestId={initialRequestId} loading={history.loading}+ onContextMenuOpenChange={(requestKey, open) => {+ setContextMenuRequestKey((openRequestKey) => {+ if (open) {+ return requestKey+ }+ return openRequestKey === requestKey ? null : openRequestKey+ })+ }} onLoadMore={history.loadMore} onSelect={setSelectedRequestKey}+ shadowRoot={shadowRoot} truncated={history.truncated} /> )}@@ -356,25 +384,33 @@ export function RequestInsightsPanel() { ) : null} </div> )++ return <CopyErrorContext value={setCopyError}>{content}</CopyErrorContext> } function VirtualRequestList({ activeRequestKey,+ contextMenuRequestKey, entries, hasMore, initialRequestId, loading,+ onContextMenuOpenChange, onLoadMore, onSelect,+ shadowRoot, truncated, }: { activeRequestKey: string | null+ contextMenuRequestKey: string | null entries: readonly RequestListEntry[] hasMore: boolean initialRequestId: string | undefined loading: boolean+ onContextMenuOpenChange: (requestKey: string, open: boolean) => void onLoadMore: () => void onSelect: (requestKey: string) => void+ shadowRoot: ShadowRoot truncated: boolean }) { const viewportRef = useRef<HTMLDivElement>(null)@@ -492,10 +528,15 @@ function VirtualRequestList({ }} > <RequestRow+ contextMenuOpen={requestKey === contextMenuRequestKey} nested={nested}+ onContextMenuOpenChange={(open) =>+ onContextMenuOpenChange(requestKey, open)+ } request={request} pageLoad={isPageLoadRequest(request, initialRequestId)} selected={requestKey === activeRequestKey}+ shadowRoot={shadowRoot} onSelect={() => onSelect(requestKey)} /> </div>@@ -680,12 +721,18 @@ function RequestRow({ nested, pageLoad, selected,+ shadowRoot,+ contextMenuOpen,+ onContextMenuOpenChange, onSelect, }: { request: RequestInsightListItem nested: boolean pageLoad: boolean selected: boolean+ shadowRoot: ShadowRoot+ contextMenuOpen: boolean+ onContextMenuOpenChange: (open: boolean) => void onSelect: () => void }) { const isInstantInsights =@@ -696,53 +743,165 @@ function RequestRow({ requestType.type === 'rsc' ) const clockTime = formatClockTime(request.startTime)- const bypassesProxy = request.proxyStatus === 'bypassed' return (- <button- aria-label={`${isInstantInsights ? `Instant Insights for ${requestUrl}` : requestUrl}, ${requestType.accessibleLabel}, ${bypassesProxy ? 'Did not match the configured proxy, ' : ''}${formatDuration(request.durationMs)}, ${clockTime}`}- className="request-insights-row"- data-internal={isInstantInsights || undefined}- data-nested={nested || undefined}- data-page-load={pageLoad}- data-selected={selected}- onClick={onSelect}- type="button"+ <ContextMenu.Root+ onOpenChange={onContextMenuOpenChange}+ open={contextMenuOpen} >- <span className="request-insights-status" data-status={request.status} />- <span className="request-insights-route">- {nested ? <NestedArrowIcon /> : null}- <span className="request-insights-route-label">- {isInstantInsights ? 'Instant Insights' : requestUrl}- </span>- </span>- <span className="request-insights-duration">- {formatDuration(request.durationMs)}- </span>- <span className="request-insights-meta request-insights-row-metadata">+ <ContextMenu.Trigger+ render={+ <button+ aria-label={`${isInstantInsights ? `Instant Insights for ${requestUrl}` : requestUrl}, ${requestType.accessibleLabel}, ${formatDuration(request.durationMs)}, ${clockTime}`}+ className="request-insights-row"+ data-internal={isInstantInsights || undefined}+ data-nested={nested || undefined}+ data-page-load={pageLoad}+ data-selected={selected}+ onClick={onSelect}+ onContextMenu={onSelect}+ type="button"+ />+ }+ > <span- className="request-insights-request-type"- data-type={requestType.type}- title={requestType.accessibleLabel}- >- {requestType.label}+ className="request-insights-status"+ data-status={request.status}+ />+ <span className="request-insights-route">+ {nested ? <NestedArrowIcon /> : null}+ <span className="request-insights-route-label">+ {isInstantInsights ? 'Instant Insights' : requestUrl}+ </span> </span>- {bypassesProxy ? (+ <span className="request-insights-duration">+ {formatDuration(request.durationMs)}+ </span>+ <span className="request-insights-meta request-insights-row-metadata"> <span- className="request-insights-request-activity"- title="This request did not match the configured proxy"+ className="request-insights-request-type"+ data-type={requestType.type}+ title={requestType.accessibleLabel} >- No proxy+ {requestType.label} </span>- ) : null}- <span>{clockTime}</span>+ <span>{clockTime}</span>+ </span>+ <span className="request-insights-meta request-insights-fetch-summary">+ {getRequestInsightFetchCount(request)+ ? `${getRequestInsightFetchCount(request)} fetch${getRequestInsightFetchCount(request) === 1 ? '' : 'es'}`+ : 'No fetches'}+ </span>+ </ContextMenu.Trigger>+ <ContextMenu.Portal container={shadowRoot}>+ <RequestInsightsContextMenuBackdrop+ onClose={() => onContextMenuOpenChange(false)}+ />+ <ContextMenu.Positioner+ className="request-insights-context-positioner"+ sideOffset={4}+ >+ <ContextMenu.Popup+ aria-label={`Actions for request ${request.requestId}`}+ className="request-insights-context-menu"+ >+ <ContextMenu.Group>+ <ContextMenu.GroupLabel className="request-insights-context-label">+ Request+ </ContextMenu.GroupLabel>+ <div className="request-insights-context-preview">+ <RequestContextMenuPreview+ request={request}+ requestUrl={requestUrl}+ />+ </div>+ </ContextMenu.Group>+ <ContextMenu.Separator className="request-insights-context-separator" />+ <RequestContextMenuItem+ getValue={() => request.requestId}+ label="Copy request ID"+ />+ <RequestContextMenuItem+ getValue={() => getRequestInsightAgentPrompt(request)}+ label="Copy agent prompt"+ />+ </ContextMenu.Popup>+ </ContextMenu.Positioner>+ </ContextMenu.Portal>+ </ContextMenu.Root>+ )+}++function RequestContextMenuPreview({+ request,+ requestUrl,+}: {+ request: RequestInsightListItem+ requestUrl: string+}) {+ const statusCode = getRequestInsightStatusCode(request)+ const fetchCount = getRequestInsightFetchCount(request)+ const spanCount = getRequestInsightSpanCount(request)++ return (+ <>+ <strong title={requestUrl}>{requestUrl}</strong>+ <span>+ {statusCode ?? request.status} ·{' '}+ {getRequestInsightSummaryTypeLabel(request)} </span>- <span className="request-insights-meta request-insights-fetch-summary">- {getRequestInsightFetchCount(request)- ? `${getRequestInsightFetchCount(request)} fetch${getRequestInsightFetchCount(request) === 1 ? '' : 'es'}`- : 'No fetches'}+ <span>+ {formatDuration(request.durationMs)} · {spanCount} span+ {spanCount === 1 ? '' : 's'} · {fetchCount} fetch+ {fetchCount === 1 ? '' : 'es'} </span>- </button>+ <code title={request.requestId}>{request.requestId}</code>+ </>+ )+}++function RequestContextMenuItem({+ getValue,+ label,+}: {+ getValue: () => string+ label: string+}) {+ const setCopyError = useContext(CopyErrorContext)++ return (+ <ContextMenu.Item+ className="request-insights-context-item"+ onClick={async () => {+ setCopyError(null)+ if (!navigator.clipboard) {+ setCopyError('Copy is unavailable. Use HTTPS or localhost.')+ return+ }+ try {+ await navigator.clipboard.writeText(getValue())+ } catch {+ setCopyError(+ 'Could not copy. Check browser permissions and try again.'+ )+ }+ }}+ >+ {label}+ </ContextMenu.Item>+ )+}++function RequestInsightsContextMenuBackdrop({+ onClose,+}: {+ onClose: () => void+}) {+ return (+ <ContextMenu.Backdrop+ className="request-insights-context-backdrop"+ onPointerDown={onClose}+ /> ) } @@ -810,10 +969,10 @@ function RequestDetails({ : requestUrl} </div> <CopyButton- actionLabel="Copy request JSON"+ actionLabel="Copy request path" className="request-insights-copy"- content={JSON.stringify(request, null, 2)}- successLabel="Copied request JSON"+ content={requestUrl}+ successLabel="Copied request path" /> </div> </div>@@ -886,9 +1045,14 @@ function Trace({ request: RequestInsight items: TraceItem[] }) {+ const { shadowRoot } = useDevOverlayContext() const [activeItemId, setActiveItemId] = useState<string | null>( items[0]?.id ?? null )+ const [hoveredItemId, setHoveredItemId] = useState<string | null>(null)+ const [contextMenuItemId, setContextMenuItemId] = useState<string | null>(+ null+ ) const [activeTraceRow, setActiveTraceRow] = useState<HTMLElement | null>(null) const [isTraceFocused, setIsTraceFocused] = useState(false) const [isTraceTooltipOpen, setIsTraceTooltipOpen] = useState(false)@@ -899,7 +1063,14 @@ function Trace({ const activeItemIndex = items.findIndex((item) => item.id === activeItemId) const safeActiveItemIndex = items.length === 0 ? -1 : Math.max(activeItemIndex, 0)- const activeItem = items[safeActiveItemIndex]+ const displayedItemId =+ contextMenuItemId ??+ hoveredItemId ??+ (isTraceFocused ? items[safeActiveItemIndex]?.id : null)+ const displayedItemIndex = items.findIndex(+ (item) => item.id === displayedItemId+ )+ const activeItem = items[displayedItemIndex] ?? items[safeActiveItemIndex] const activeItemDescription = activeItem ? getTraceItemDescription(activeItem, range) : null@@ -947,6 +1118,7 @@ function Trace({ event.preventDefault() shouldScrollActiveItemIntoViewRef.current = true+ setHoveredItemId(null) setActiveItemId(items[nextIndex]?.id ?? null) }, [activeItemIndex, items]@@ -968,6 +1140,7 @@ function Trace({ shouldScrollActiveItemIntoViewRef.current = false const itemId = items[index]?.id if (itemId !== undefined) {+ setHoveredItemId(itemId) setActiveItemId((currentItemId) => currentItemId === itemId ? currentItemId : itemId )@@ -989,6 +1162,7 @@ function Trace({ <div className="request-insights-trace-viewport"> <div className="request-insights-trace"+ onPointerLeave={() => setHoveredItemId(null)} onPointerMove={handleTracePointerMove} > <div className="request-insights-trace-header">@@ -1026,7 +1200,7 @@ function Trace({ > <div aria-activedescendant={- safeActiveItemIndex === -1+ !isTraceFocused || safeActiveItemIndex === -1 ? undefined : `${traceId}-item-${safeActiveItemIndex}` }@@ -1044,50 +1218,68 @@ function Trace({ const description = getTraceItemDescription(item, range) return (- <div- aria-label={description}- aria-selected={index === safeActiveItemIndex}- className="request-insights-span-row"- data-active={index === safeActiveItemIndex || undefined}- data-kind={item.kind}- data-trace-item-id={item.id}- data-trace-index={index}- id={`${traceId}-item-${index}`}+ <TraceSpanContextMenu+ item={item} key={item.id}- ref={- index === safeActiveItemIndex- ? setActiveTraceRow- : undefined- }- role="option"+ onOpenChange={(open) => {+ setContextMenuItemId((openItemId) => {+ if (open) {+ return item.id+ }+ return openItemId === item.id ? null : openItemId+ })+ }}+ open={contextMenuItemId === item.id}+ request={request}+ shadowRoot={shadowRoot} >- <span- className="request-insights-span-name"- style={{ paddingLeft: `${item.depth * 14 + 4}px` }}+ <div+ aria-label={description}+ aria-selected={+ isTraceFocused && index === safeActiveItemIndex+ }+ className="request-insights-span-row"+ data-active={index === displayedItemIndex || undefined}+ data-kind={item.kind}+ data-trace-item-id={item.id}+ data-trace-index={index}+ id={`${traceId}-item-${index}`}+ onContextMenu={() => setActiveItemId(item.id)}+ ref={+ index === displayedItemIndex+ ? setActiveTraceRow+ : undefined+ }+ role="option" >- <span className="request-insights-span-label">+ <span+ className="request-insights-span-name"+ style={{ paddingLeft: `${item.depth * 14 + 4}px` }}+ >+ <span className="request-insights-span-label">+ <span+ className="request-insights-span-marker"+ data-kind={item.kind}+ data-status={item.status}+ />+ <span>{item.label}</span>+ </span>+ </span>+ <span className="request-insights-span-track"> <span- className="request-insights-span-marker"- data-kind={item.kind}+ className="request-insights-span-bar" data-status={item.status}+ style={{+ left: `${position.left}%`,+ width: `${position.width}%`,+ }} />- <span>{item.label}</span> </span>- </span>- <span className="request-insights-span-track">- <span- className="request-insights-span-bar"- data-status={item.status}- style={{- left: `${position.left}%`,- width: `${position.width}%`,- }}- />- </span>- <span className="request-insights-span-duration">- {formatDuration(item.durationMs)}- </span>- </div>+ <span className="request-insights-span-duration">+ {formatDuration(item.durationMs)}+ </span>+ </div>+ </TraceSpanContextMenu> ) })} </div>@@ -1098,6 +1290,77 @@ function Trace({ ) } +function TraceSpanContextMenu({+ children,+ item,+ onOpenChange,+ open,+ request,+ shadowRoot,+}: {+ children: ReactElement<Record<string, unknown>>+ item: TraceItem+ onOpenChange: (open: boolean) => void+ open: boolean+ request: RequestInsight+ shadowRoot: ShadowRoot+}) {+ const spanId = item.spanId+ if (!spanId) {+ return children+ }++ const label = item.fullLabel ?? item.label++ return (+ <ContextMenu.Root onOpenChange={onOpenChange} open={open}>+ <ContextMenu.Trigger render={children} />+ <ContextMenu.Portal container={shadowRoot}>+ <RequestInsightsContextMenuBackdrop+ onClose={() => onOpenChange(false)}+ />+ <ContextMenu.Positioner+ className="request-insights-context-positioner"+ sideOffset={4}+ >+ <ContextMenu.Popup+ aria-label={`Actions for span ${spanId}`}+ className="request-insights-context-menu"+ >+ <ContextMenu.Group>+ <ContextMenu.GroupLabel className="request-insights-context-label">+ Span+ </ContextMenu.GroupLabel>+ <div className="request-insights-context-preview">+ <strong title={label}>{label}</strong>+ <span>+ {item.category === 'nextjs' ? 'Next.js' : 'Application'} ·{' '}+ {formatDuration(item.durationMs)} · {item.status}+ </span>+ <code title={spanId}>{spanId}</code>+ </div>+ </ContextMenu.Group>+ <ContextMenu.Separator className="request-insights-context-separator" />+ <RequestContextMenuItem+ getValue={() => spanId}+ label="Copy span ID"+ />+ <RequestContextMenuItem+ getValue={() =>+ getRequestInsightSpanAgentPrompt(request.requestId, {+ spanId,+ label,+ })+ }+ label="Copy agent prompt"+ />+ </ContextMenu.Popup>+ </ContextMenu.Positioner>+ </ContextMenu.Portal>+ </ContextMenu.Root>+ )+}+ function getTraceItemDescription( item: TraceItem, range: ReturnType<typeof getTraceRange>packages/next/src/next-devtools/dev-overlay/components/request-insights/request-label.test.ts16 + / 0 −
@@ -1,6 +1,8 @@ import { formatRequestRouteParams, getRequestDisplayUrl,+ getRequestInsightAgentPrompt,+ getRequestInsightSpanAgentPrompt, getRequestListDisplayUrl, getRequestRouteParams, } from './request-label'@@ -84,4 +86,18 @@ describe('request insight request labels', () => { }) ).toBeUndefined() })++ it('formats request IDs and agent prompts for the context menu', () => {+ expect(getRequestInsightAgentPrompt({ requestId: 'request-1' })).toBe(+ 'Inspect Request Insights request request-1 with the get_request_insights MCP tool. Focus only on its server fetches. Trace slow, failed, or uncached fetches back to the application code, fix the underlying issue, and verify the request again in Request Insights.'+ )+ expect(+ getRequestInsightSpanAgentPrompt('request-1', {+ spanId: 'span-1',+ label: 'render',+ })+ ).toBe(+ 'Inspect Request Insights request request-1 with the get_request_insights MCP tool and focus on span span-1 (render). Explain what the span did, where its time went, and whether its children, fetches, cache behavior, or errors explain the duration.'+ )+ }) })packages/next/src/next-devtools/dev-overlay/components/request-insights/request-label.ts13 + / 0 −
@@ -12,6 +12,19 @@ export function getRequestDisplayUrl(request: RequestIdentity): string { return request.url ?? request.route ?? request.requestId } +export function getRequestInsightAgentPrompt(+ request: Pick<RequestInsight, 'requestId'>+): string {+ return `Inspect Request Insights request ${request.requestId} with the get_request_insights MCP tool. Focus only on its server fetches. Trace slow, failed, or uncached fetches back to the application code, fix the underlying issue, and verify the request again in Request Insights.`+}++export function getRequestInsightSpanAgentPrompt(+ requestId: string,+ span: { spanId: string; label: string }+): string {+ return `Inspect Request Insights request ${requestId} with the get_request_insights MCP tool and focus on span ${span.spanId} (${span.label}). Explain what the span did, where its time went, and whether its children, fetches, cache behavior, or errors explain the duration.`+}+ export function getRequestListDisplayUrl( request: RequestIdentity, rscRequest: booleantest/development/app-dir/request-insights/request-insights.test.ts319 + / 3 −
@@ -1189,6 +1189,320 @@ describe('request insights', () => { ).toBe(true) expect(details.text).not.toContain('Q2_SECRET_SENTINEL') })++ const requestHeader = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const copyButton = root?.querySelector<HTMLButtonElement>(+ '.request-insights-summary button'+ )+ return {+ copyButtonCount:+ root?.querySelectorAll('.request-insights-summary button').length ??+ 0,+ copyButtonLabel: copyButton?.getAttribute('aria-label') ?? '',+ text:+ root?.querySelector('.request-insights-summary')?.textContent ?? '',+ }+ })+ expect(requestHeader.copyButtonCount).toBe(1)+ expect(requestHeader.copyButtonLabel).toBe('Copy request path')+ expect(requestHeader.text).not.toContain('Request ID')++ await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const row = Array.from(+ root?.querySelectorAll<HTMLButtonElement>('.request-insights-row') ?? []+ ).find((candidate) =>+ candidate.textContent?.includes('/products/blue?query=redacted')+ )+ const rect = row?.getBoundingClientRect()+ row?.dispatchEvent(+ new MouseEvent('contextmenu', {+ bubbles: true,+ cancelable: true,+ clientX: rect?.left ?? 0,+ clientY: rect?.top ?? 0,+ })+ )+ })++ await retry(async () => {+ const menu = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const popup = root?.querySelector<HTMLElement>(+ '.request-insights-context-menu'+ )+ const requestIdPreview = popup?.querySelector<HTMLElement>(+ '.request-insights-context-preview code'+ )+ const requestIdStyle = requestIdPreview+ ? getComputedStyle(requestIdPreview)+ : null+ return {+ label: popup?.getAttribute('aria-label') ?? '',+ requestId: requestIdPreview?.textContent?.trim() ?? '',+ requestIdOverflowWrap: requestIdStyle?.overflowWrap ?? '',+ requestIdTitle: requestIdPreview?.title ?? '',+ text: popup?.textContent ?? '',+ width: popup?.getBoundingClientRect().width ?? 0,+ }+ })++ expect(menu.requestId).not.toBe('')+ expect(menu.label).toBe(`Actions for request ${menu.requestId}`)+ expect(menu.requestIdTitle).toBe(menu.requestId)+ expect(menu.requestIdOverflowWrap).toBe('anywhere')+ expect(menu.text).toContain('/products/blue?query=redacted')+ expect(menu.text).toContain('Copy request ID')+ expect(menu.text).toContain('Copy agent prompt')+ expect(menu.text).not.toContain('Copy page-load ID')+ expect(menu.text).not.toContain('Copy request URL')+ expect(menu.text).not.toContain('Copy request JSON')+ expect(menu.width).toBeLessThanOrEqual(260)+ })++ const openedSecondMenu = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const rows = Array.from(+ root?.querySelectorAll<HTMLButtonElement>('.request-insights-row') ?? []+ )+ const row = rows.find(+ (candidate) =>+ !candidate.textContent?.includes('/products/blue?query=redacted')+ )+ const rect = row?.getBoundingClientRect()+ row?.dispatchEvent(+ new MouseEvent('contextmenu', {+ bubbles: true,+ cancelable: true,+ clientX: rect?.left ?? 0,+ clientY: rect?.top ?? 0,+ })+ )+ return row !== undefined+ })+ expect(openedSecondMenu).toBe(true)++ const selectedRequestMenu = await retry(async () => {+ const state = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const popups = root?.querySelectorAll<HTMLElement>(+ '.request-insights-context-menu'+ )+ return {+ popupCount: popups?.length ?? 0,+ popupLabel: popups?.[0]?.getAttribute('aria-label') ?? '',+ selectedRequestId:+ popups?.[0]+ ?.querySelector('.request-insights-context-preview code')+ ?.textContent?.trim() ?? '',+ }+ })++ expect(state.popupCount).toBe(1)+ expect(state.selectedRequestId).not.toBe('')+ expect(state.popupLabel).toBe(+ `Actions for request ${state.selectedRequestId}`+ )+ return state+ })+ const selectedRequestId = selectedRequestMenu.selectedRequestId++ const clickedOutsideMenu = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const backdrop = root?.querySelector<HTMLElement>(+ '.request-insights-context-backdrop'+ )+ backdrop?.dispatchEvent(+ new PointerEvent('pointerdown', {+ bubbles: true,+ cancelable: true,+ composed: true,+ pointerType: 'mouse',+ })+ )+ backdrop?.dispatchEvent(+ new MouseEvent('click', {+ bubbles: true,+ cancelable: true,+ composed: true,+ })+ )+ return Boolean(backdrop)+ })+ expect(clickedOutsideMenu).toBe(true)++ await retry(async () => {+ expect(+ await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ return root?.querySelectorAll('.request-insights-context-menu').length+ })+ ).toBe(0)+ })++ await browser.eval(() => {+ const testWindow = window as typeof window & {+ __requestInsightsCopiedValue?: string+ }+ Object.defineProperty(navigator, 'clipboard', {+ configurable: true,+ value: {+ writeText: async (value: string) => {+ testWindow.__requestInsightsCopiedValue = value+ },+ },+ })+ })+ const firstSpan = browser+ .locator('nextjs-portal .request-insights-span-row')+ .first()+ await firstSpan.click({ button: 'right' })++ const spanMenu = await retry(async () => {+ const state = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const popup = root?.querySelector<HTMLElement>(+ '.request-insights-context-menu'+ )+ return {+ label: popup?.getAttribute('aria-label') ?? '',+ text: popup?.textContent ?? '',+ }+ })++ expect(state.label).toMatch(/^Actions for span .+/)+ expect(state.text).toContain('Copy span ID')+ expect(state.text).toContain('Copy agent prompt')+ return state+ })+ const spanId = spanMenu.label.slice('Actions for span '.length)++ await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const copySpanId = Array.from(+ root?.querySelectorAll<HTMLElement>('.request-insights-context-item') ??+ []+ ).find((item) => item.textContent === 'Copy span ID')+ copySpanId?.click()+ })++ await retry(async () => {+ const state = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const testWindow = window as typeof window & {+ __requestInsightsCopiedValue?: string+ }+ return {+ copied: testWindow.__requestInsightsCopiedValue,+ popupCount:+ root?.querySelectorAll('.request-insights-context-menu').length ??+ 0,+ }+ })+ expect(state.copied).toBe(spanId)+ expect(state.popupCount).toBe(0)+ })++ await firstSpan.click({ button: 'right' })++ await retry(async () => {+ const clicked = await browser.eval(() => {+ const root = document.querySelector('nextjs-portal')?.shadowRoot+ const copyPrompt = Array.from(+ root?.querySelectorAll<HTMLElement>(+ '.request-insights-context-item'+ ) ?? []+ ).find((item) => item.textContent === 'Copy agent prompt')+ copyPrompt?.click()+ return copyPrompt !== undefined+ })+ expect(clicked).toBe(true)+ })++ await retry(async () => {+ const copied = await browser.eval(() => {+ const testWindow = window as typeof window & {+ __requestInsightsCopiedValue?: string+ }+ return testWindow.__requestInsightsCopiedValue ?? ''+ })+ expect(copied).toContain(selectedRequestId)+ expect(copied).toContain(spanId)+ })++ for (const failure of ['unavailable', 'rejected']) {+ await browser.eval((mode: string) => {+ Object.defineProperty(navigator, 'clipboard', {+ configurable: true,+ value:+ mode === 'unavailable'+ ? undefined+ : {+ writeText: async () => {+ throw new DOMException(+ 'Permission denied',+ 'NotAllowedError'+ )+ },+ },+ })+ }, failure)+ await firstSpan.click({ button: 'right' })+ await browser+ .locator('nextjs-portal .request-insights-context-item')+ .filter({ hasText: 'Copy span ID' })+ .click()+ await retry(async () => {+ const message = await browser+ .elementByCss('.request-insights-copy-error[role="alert"]')+ .text()+ expect(message).toBe(+ failure === 'unavailable'+ ? 'Copy is unavailable. Use HTTPS or localhost.'+ : 'Could not copy. Check browser permissions and try again.'+ )+ expect(+ await browser+ .locator('nextjs-portal .request-insights-context-menu')+ .count()+ ).toBe(0)+ })+ }+ })++ it('shows trace tooltips on hover and clears the preview outside the trace', async () => {+ const browser = await next.browser('/')+ await openRequestInsightsPanel(browser)++ const activeRows = browser.locator(+ 'nextjs-portal .request-insights-span-row[data-active="true"]'+ )+ const tooltip = browser.locator(+ 'nextjs-portal .request-insights-trace-tooltip'+ )+ await browser+ .locator('nextjs-portal .request-insights-span-row')+ .first()+ .hover()+ await retry(async () => {+ expect(await activeRows.count()).toBe(1)+ expect(await tooltip.isVisible()).toBe(true)+ })++ await browser.locator('nextjs-portal .request-insights-overview').hover()+ await retry(async () => {+ expect(await activeRows.count()).toBe(0)+ expect(await tooltip.isVisible()).toBe(false)+ })++ await browser+ .locator('nextjs-portal .request-insights-span-row')+ .first()+ .hover()+ await retry(async () => {+ expect(await tooltip.isVisible()).toBe(true)+ }) }) it('keeps trace inspection anchored while the panel is resized', async () => {@@ -1211,7 +1525,8 @@ describe('request insights', () => { }) await browser- .locator('nextjs-portal .request-insights-span-row[data-active="true"]')+ .locator('nextjs-portal .request-insights-span-row')+ .first() .hover() await browser.locator('nextjs-portal .request-insights-trace-rows').focus() @@ -1365,11 +1680,12 @@ describe('request insights', () => { expect(selection.selected).toBe('true') expect(selection.title).toBe('/api/source?query=redacted') expect(selection.firstRowId).toEqual(expect.any(String))- expect(selection.activeRowId).toBe(selection.firstRowId)+ expect(selection.activeRowId).toBeUndefined() }) await browser- .locator('nextjs-portal .request-insights-span-row[data-active="true"]')+ .locator('nextjs-portal .request-insights-span-row')+ .first() .hover() await browser.locator('nextjs-portal .request-insights-trace-rows').focus()