-
-
+
+
+
Find the code that makes your app feel slow
+
Turn profiler traces into a focused list of bottlenecks
+
+
React
+
JavaScript
+
React Native
+
+
+
setHeld(true)}
+ onMouseLeave={() => setHeld(false)}
+ onFocus={() => setHeld(true)}
+ onBlur={() => setHeld(false)}
+ >
+ {/* Every slide stays in the track, so its height is the tallest of
+ them and the card does not resize under the reader mid-swap. */}
+ {/* The current slide centred, with the slides either side of it
+ shrunk and held back to a fraction of their opacity: the strip
+ reads as a row that moves rather than a card that is replaced. */}
+
+ {SLIDE_CELLS.map((type, index) => (
+
show(index % SLIDE_ORDER.length)}
+ >
+
+
+ ))}
+
+
+
+ {SLIDE_ORDER.map((type, index) => (
+
);
}
@@ -227,6 +525,25 @@ function PromptActionButton({
const MAX_RESULT_CARDS = 3;
+/**
+ * Component rows a React card shows. Eight is what the cards' own culprit floor
+ * admits on a realistic commit, and past it the footnote says as much as another
+ * row would.
+ */
+const MAX_REACT_CARD_ROWS = 8;
+
+/**
+ * The smallest share of a commit worth its own row.
+ *
+ * The cards' own culprit floor (0.5 ms, or 1% of the commit) is set for the
+ * hand-off, where an agent reading the repo can use a small component as a
+ * hint. On the card it produced rows like `VirtualizedList 3 ms / 1% of
+ * commit`: true, measured, and nothing a reader can act on, sitting at the
+ * same visual weight as the 142 ms row above it. Five percent is the point
+ * where fixing the component could plausibly move the commit.
+ */
+const REACT_ROW_MIN_SHARE = 5;
+
function reactIssueRemainingMs(issue: ReactIssue): number {
return Math.max(0, issue.commit.durationMs - issue.components.reduce((total, component) => total + component.selfTimeMs, 0));
}
@@ -238,35 +555,284 @@ interface HotPathRow {
title?: string;
ms?: number;
percentLabel?: string;
+ /** The second of a row's two figures, e.g. self time beside total time. */
+ secondaryLabel?: string;
barPercent?: number;
caption?: string;
+ /**
+ * How this row's time was distributed over separate calls. The single most
+ * actionable line on a task card: one 424 ms call is an algorithm to fix and
+ * 54 calls of 8 ms is a call site to stop hitting, and a total alone renders
+ * both identically.
+ */
+ shape?: string;
+ /**
+ * The named callers that reached this row's frame, outermost first. A
+ * function name alone rarely says what to do about it; the frames that called
+ * it place it in a feature and usually in a loop.
+ */
+ callers?: string;
+ /** Opens Explore on this row's frame inside the task. */
+ onFocus?: () => void;
}
interface HotPathCard {
rows: HotPathRow[];
- footnote?: string;
+ footnotes: string[];
+}
+
+/**
+ * A caller sampled in several bursts is one bottleneck, but its total and its
+ * worst single burst answer different questions: how much work it costs overall,
+ * and how much of that lands in one block a user could feel.
+ */
+function occurrenceLabel(hotspot: Hotspot): string | undefined {
+ if (!hotspot.occurrences || hotspot.occurrences < 2) return undefined;
+ return `${hotspot.occurrences} bursts · longest ${formatMs(hotspot.longestRunMs)}`;
}
function hotspotRows(hotspot: Hotspot): HotPathCard {
+ // The name is measured; only the explanation comes from the agent. Keying the
+ // explanation by function id keeps a row's caption about that row's function.
const ranked = hotspot.functions
- .map((fn, index) => ({ fn, detail: hotspot.summary[index] }))
+ .map((fn) => ({ fn, detail: hotspot.evidence?.[fn.id] }))
.sort((a, b) => b.fn.selfTimeMs - a.fn.selfTimeMs);
const shown = ranked.slice(0, MAX_RESULT_CARDS);
const rows: HotPathRow[] = shown.map(({ fn, detail }) => ({
location: fn.location,
+ title: fn.title,
ms: fn.selfTimeMs,
percentLabel: `${Math.round(fn.percentOfGroup)}% of group`,
barPercent: fn.percentOfGroup,
caption: detail,
}));
- const attributedMs = ranked.reduce((sum, { fn }) => sum + fn.selfTimeMs, 0);
- const leftoverMs = hotspot.combinedTimeMs - attributedMs;
- const footnote = leftoverMs >= 1
- ? `+ ~${formatMs(leftoverMs)} other time not attributed to a named function`
- : undefined;
+ // Only the top rows are published, so the group's own named total has to come
+ // from the server; subtracting the shown rows alone reported measured functions
+ // as unattributed time.
+ const shownMs = shown.reduce((sum, { fn }) => sum + fn.selfTimeMs, 0);
+ const namedMs = hotspot.namedTimeMs ?? ranked.reduce((sum, { fn }) => sum + fn.selfTimeMs, 0);
+ const unlistedCount = (hotspot.namedFunctionCount ?? ranked.length) - shown.length;
+ const unlistedMs = Math.max(0, namedMs - shownMs);
+ const unnamedMs = Math.max(0, hotspot.combinedTimeMs - namedMs);
+
+ const footnotes: string[] = [];
+ if (unlistedCount > 0 && unlistedMs >= 1) {
+ footnotes.push(`+ ~${formatMs(unlistedMs)} across ${unlistedCount} further measured function${unlistedCount === 1 ? "" : "s"}`);
+ }
+ // The naming threshold scales with the profile's sampling interval, so it has
+ // to be read off the group rather than restated as the group-ranking constant.
+ if (unnamedMs >= 1) {
+ const cutoff = hotspot.minFunctionTimeMs ?? MIN_HOTSPOT_TIME_MS;
+ footnotes.push(`+ ~${formatMs(unnamedMs)} spread thinly across functions under ${formatMs(cutoff)} each`);
+ }
+
+ return { rows, footnotes };
+}
+
+/**
+ * A card's "Main highlights": the heaviest named work inside the parent's
+ * subtree, each with both of its times. Inclusive time is what ranks them —
+ * self time alone cannot say that a 240 ms helper sits under this parent.
+ */
+function cardRows(card: ProfileCard): HotPathCard {
+ const locations = profileCardLocations(card);
+ const rows: HotPathRow[] = card.highlights.map((highlight) => ({
+ location: locations.shown(highlight.name, highlight.location),
+ title: highlight.name,
+ ms: highlight.totalMs,
+ secondaryLabel: `${formatMs(highlight.selfMs)} self`,
+ percentLabel: card.totalMs > 0 ? `${Math.round((highlight.totalMs / card.totalMs) * 100)}% of parent` : undefined,
+ barPercent: card.totalMs > 0 ? (highlight.totalMs / card.totalMs) * 100 : 0,
+ }));
+
+ const footnotes: string[] = [];
+ footnotes.push(
+ card.selfShape === "longTail"
+ ? `${formatMs(card.selfMs)} is spread across calls too small to list`
+ : `${formatMs(card.selfMs)} in its own body`
+ );
+ if (card.subtreeFunctionCount > card.highlights.length) {
+ footnotes.push(`${card.subtreeFunctionCount} functions ran under this parent in total`);
+ }
+ if (card.confidence === "low") {
+ footnotes.push("few samples landed here — treat these figures as a hint");
+ }
+ return { rows, footnotes };
+}
+
+/**
+ * A task card's content is a drawing of how the block divides between the
+ * features it entered, plus the footnotes that qualify the measurement.
+ *
+ * It was a list of culprit rows until the chart replaced it, and before that a
+ * list of boundary frames with inclusive-time bars. Both were readings of a
+ * card as eight independent rows, which is the thing the chart is not: the rows
+ * shared no whole, so a reader could not see that the eight heaviest culprits
+ * of a 3227 ms task account for 30% of it, nor what the other 70% was.
+ *
+ * Dividing by boundary frame instead is what makes the whole say something. See
+ * `contribution.ts` for why both levels of the chart are honest partitions, and
+ * why the residuals are slices rather than footnotes — which is also why the
+ * footnotes below no longer describe the remainder.
+ */
+function taskCardFootnotes(card: TaskCard): string[] {
+ const footnotes: string[] = [];
+ if (card.confidence === "low") footnotes.push("few samples landed in this task — treat these figures as a hint");
+ return footnotes;
+}
+
+/**
+ * A task that entered no frame of its own — a bundle whose identifiers are all
+ * mangled, or work that really was all framework — has no features to divide
+ * into, so the chart could only say "none of this was your code" and offer
+ * nothing to open. Its culprits can still name functions, so the card falls
+ * back to the rows they used to be drawn as.
+ */
+function taskFallbackRows(card: TaskCard, onFocus: (nodeId: string) => void): HotPathRow[] {
+ const locations = taskCardLocations(card);
+ return card.culprits.slice(0, CHART_SLICES).map((culprit) => ({
+ location: locations.shown(culprit.name, culprit.location),
+ title: culprit.name,
+ ms: culprit.selfMs,
+ // Only when the two differ. A leaf burns all of its own time, so printing
+ // `1.57 s` beside `1.57 s with callees` says the same thing twice.
+ secondaryLabel: culprit.totalMs - culprit.selfMs >= 1 ? `${formatMs(culprit.totalMs)} with callees` : undefined,
+ percentLabel: card.durationMs > 0 ? `${Math.round((culprit.selfMs / card.durationMs) * 100)}% of task` : undefined,
+ barPercent: card.durationMs > 0 ? (culprit.selfMs / card.durationMs) * 100 : 0,
+ shape: culprit.shapeText,
+ callers: culprit.callers.length > 0 ? culprit.callers.join(" › ") : undefined,
+ onFocus: () => onFocus(culprit.nodeId),
+ }));
+}
+
+/* A task card's heading used to carry a third line naming the rest of the
+ boundary frames ("also keysChanged 306 ms · …"). The breakdown beneath the
+ chart already names every one of them with its share, so the line repeated
+ the evidence in a form you could not click. The heading now stops at the
+ shape. */
+
+/**
+ * Which drawing the cards below are read as. One control for both families: a
+ * reader who prefers one reading of a part-of-whole prefers it on a commit for
+ * the same reason they prefer it on a task.
+ */
+/*
+ * Missing render reasons are a property of the recording, not of any one
+ * commit, so this is said once for the whole run. It is a light rather than a
+ * paragraph: most recordings have nothing to say here, and the sentence only
+ * matters to someone who noticed a card saying "render reason not recorded"
+ * and went looking for why.
+ */
+function CausesNotice() {
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef
(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ const onPointerDown = (event: PointerEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
+ };
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("pointerdown", onPointerDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("pointerdown", onPointerDown);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+
Render reasons not recorded
+
+ This recording does not say why each component rendered. Re-record with React DevTools’
+ “Record why each component rendered” setting on to get render causes.
+
+
+ ) : null}
+
+ );
+}
+
+function ChartSwitch({
+ kind,
+ onChange,
+}: {
+ kind: ContributionChartKind;
+ onChange: (kind: ContributionChartKind) => void;
+}) {
+ return (
+
+ {(["bar", "treemap"] as const).map((option) => (
+
+ ))}
+
+ );
+}
+
+/** The second line of a card: how often this frame ran. */
+function cardSubtitle(card: ProfileCard, callCountIsExact: boolean): string | undefined {
+ const count = callCountIsExact ? card.invocations ?? card.callSites : card.callSites;
+ if (count <= 1) return undefined;
+ return callCountIsExact ? `called ${count} times` : `${count} call sites`;
+}
- return { rows, footnote };
+/**
+ * A React card's rows: the components that burned the commit's own time.
+ *
+ * The fallback the chart leaves behind. A commit whose components are all under
+ * the culprit floor — three hundred fibers at 0.1 ms each on a 20 ms commit —
+ * divides into nothing but residuals, so the chart could only say that none of
+ * it was any one component's fault. Its heaviest component is still a name, and
+ * these rows are where it is printed.
+ *
+ * `percentOfCommit` rather than a share of the summed self time, because the
+ * commit's duration is the figure the heading claims and the one a reader can
+ * check against the recording.
+ */
+function reactCardRows(card: ReactCard): HotPathCard {
+ // The top culprit is always shown, however small: a card exists because its
+ // commit went over budget, and a card with no rows states the problem while
+ // withholding the only name it has for it.
+ const worthARow = card.culprits.filter((culprit) => culprit.percentOfCommit >= REACT_ROW_MIN_SHARE);
+ const shown = (worthARow.length > 0 ? worthARow : card.culprits.slice(0, 1)).slice(0, MAX_REACT_CARD_ROWS);
+ const rows: HotPathRow[] = shown.map((culprit) => ({
+ location: culprit.sourceHint ?? undefined,
+ title: culprit.component,
+ ms: culprit.selfMs,
+ percentLabel: `${Math.round(culprit.percentOfCommit)}% of commit`,
+ barPercent: culprit.percentOfCommit,
+ shape: reactCulpritShape(culprit),
+ callers: culprit.path.length > 1 ? culprit.path.slice(0, -1).join(" › ") : undefined,
+ }));
+ /* No footnotes. The three this card used to carry — the sub-floor tail, the
+ reconciler residual, and the effect durations — were each a sentence of
+ arithmetic about time no component is responsible for, which is time the
+ reader cannot act on. They are still in the record, and still in the
+ hand-off, where an agent that can read the repo can use them. */
+ return { rows, footnotes: [] };
}
function reactIssueRows(issue: ReactIssue): HotPathCard {
@@ -279,44 +845,99 @@ function reactIssueRows(issue: ReactIssue): HotPathCard {
barPercent: component.percentOfCommit,
caption: component.evidence.trim() || undefined,
})),
- footnote: remainingMs >= 1
- ? `+ ~${formatMs(remainingMs)} other commit work not represented by these findings`
- : undefined,
+ footnotes: remainingMs >= 1
+ ? [`+ ~${formatMs(remainingMs)} other commit work not represented by these findings`]
+ : [],
};
}
function AnalysisResultCard({
rank,
title,
+ shape,
+ subtitle,
timeLabel,
+ insight,
+ onExplain,
+ explainDisabledReason,
+ explaining,
+ insightError,
+ chart,
rows,
- footnote,
+ footnotes,
loading,
error,
copied,
busy,
onCopy,
+ onExplore,
}: {
rank: number;
title: string;
+ /**
+ * The second heading row: the measured shape of the cost, where the card has
+ * one. Under evaluation beside `title`, so it is optional and the legacy
+ * engines below simply do not pass it.
+ */
+ shape?: string;
+ subtitle?: string;
timeLabel: string;
+ /**
+ * The model's reading of this finding, once asked for. Labelled on the
+ * card because it is the only thing there that was not measured, and placed
+ * below the rows: the reading is the conclusion the evidence above leads to,
+ * and it lands next to the button that asked for it.
+ */
+ insight?: string[];
+ /** Asks a model to read this task. Absent where no inference is on offer. */
+ onExplain?: () => void;
+ /**
+ * Why the reading cannot be asked for yet, which is only ever a missing
+ * model. The button stays on the card and carries this as its tooltip: the
+ * reading is the one thing here a model is needed for, so the card is where
+ * that is worth saying.
+ */
+ explainDisabledReason?: string;
+ explaining?: boolean;
+ insightError?: string;
+ /**
+ * The evidence as a drawing rather than as rows. Where a finding divides into
+ * parts of a whole it is the better reading, and where it does not — a React
+ * commit, a hotspot group — the card still takes rows.
+ */
+ chart?: ReactNode;
rows: HotPathRow[];
- footnote?: string;
+ footnotes: string[];
loading: boolean;
error?: string;
copied: boolean;
busy: boolean;
onCopy: () => void;
+ onExplore?: () => void;
}) {
+ /* The reading opens with the card that produced it; hiding it is for getting
+ back to the measured evidence without losing the reading. */
+ const [readingOpen, setReadingOpen] = useState(true);
+ const hasReading = Boolean(insight && insight.length > 0);
return (
#{rank}
{title}
+ {shape ? {shape} : null}
+ {subtitle ? {subtitle} : null}
{timeLabel}
+ {chart}
+ {/* The footnotes qualify whatever evidence the card carried, so they
+ belong to the chart as much as to the rows. */}
+ {chart && rows.length === 0 && footnotes.length > 0 ? (
+
+ {footnotes.map((footnote) =>
{footnote}
)}
+
+ ) : null}
{rows.length > 0 && (
{rows.map((row, rowIndex) => (
@@ -324,14 +945,25 @@ function AnalysisResultCard({
{row.location ? (
file
- {row.location}
+ {shortLocationLabel(row.location)}
+
+ ) : null}
+ {row.title ? (
+ row.onFocus
+ ?
+ :
{row.title}
+ ) : null}
+ {row.callers ? (
+
+ via
+ {row.callers}
) : null}
- {row.title ?
{row.title} : null}
{row.ms !== undefined && (
<>
{formatMs(row.ms)}
+ {row.secondaryLabel ? {row.secondaryLabel} : null}
{row.percentLabel ? {row.percentLabel} : null}
@@ -339,21 +971,67 @@ function AnalysisResultCard({
>
)}
+ {row.shape ?
{row.shape}
: null}
{row.caption ?
{row.caption}
: null}
))}
- {footnote ? {footnote}
: null}
+ {footnotes.map((footnote) => {footnote}
)}
)}
+ {insight && insight.length > 0 && readingOpen ? (
+
{error ?
{error}
: null}
-
-
+
+
+ {hasReading ? (
+
+ ) : onExplain ? (
+ // The tooltip sits on the wrapper, not the button: a disabled
+ // button takes `pointer-events: none`, so its own `title` never
+ // surfaces.
+
+
+
+ ) : null}
+
+
+ {onExplore ? (
+
+ ) : null}
+
+
@@ -442,29 +1120,8 @@ function UploadPane({
);
}
-const ROZENITE_THEME_KEY = "@rozenite/ui:theme";
-
export default function Home() {
- const [themeReady, setThemeReady] = useState(false);
-
- useEffect(() => {
- try {
- const stored = localStorage.getItem(ROZENITE_THEME_KEY);
- if (stored !== "light" && stored !== "dark") {
- localStorage.setItem(ROZENITE_THEME_KEY, "dark");
- }
- } catch {
- // Theme still applies for this session even if storage is unavailable.
- }
- const frame = requestAnimationFrame(() => setThemeReady(true));
- return () => cancelAnimationFrame(frame);
- }, []);
-
- if (!themeReady) {
- return
;
- }
-
- return
;
+ return
;
}
function InspectorApp() {
@@ -476,6 +1133,12 @@ function InspectorApp() {
const [history, setHistory] = useState
([]);
const [profileType, setProfileType] = useState("javascript");
const [showWelcome, setShowWelcome] = useState(true);
+ /* A slide is a choice of profile, not just a way past the landing page: it
+ leaves the reader on the upload step for the kind they clicked. */
+ const startWith = (type: ProfileType) => {
+ setProfileType(type);
+ setShowWelcome(false);
+ };
const [files, setFiles] = useState>>({});
const [modelStatus, setModelStatus] = useState(null);
const [selectedProvider, setSelectedProvider] = useState("");
@@ -506,7 +1169,9 @@ function InspectorApp() {
};
useEffect(() => {
void refreshHistory();
- fetch("/api/analysis-settings", { cache: "no-store" }).then(r => r.json()).then(data => setAutoSave(data.autoSave !== false)).catch(() => undefined);
+ fetch("/api/analysis-settings", { cache: "no-store" }).then(r => r.json()).then(data => {
+ setAutoSave(data.autoSave !== false);
+ }).catch(() => undefined);
}, []);
useEffect(() => {
@@ -536,8 +1201,12 @@ function InspectorApp() {
const [analyzedType, setAnalyzedType] = useState("javascript");
const [totalMs, setTotalMs] = useState(0);
const [hotspots, setHotspots] = useState([]);
+ const [taskCards, setTaskCards] = useState(null);
+ const [cards, setCards] = useState([]);
+ const [callCountIsExact, setCallCountIsExact] = useState(false);
const [frameBudget, setFrameBudget] = useState("16");
const [appliedBudget, setAppliedBudget] = useState(16);
+ const [reactCards, setReactCards] = useState(null);
const [reactIssues, setReactIssues] = useState([]);
const [reactSummary, setReactSummary] = useState(null);
const [prompts, setPrompts] = useState>({});
@@ -546,16 +1215,25 @@ function InspectorApp() {
const [promptLoadingId, setPromptLoadingId] = useState(null);
const [promptErrors, setPromptErrors] = useState>({});
const [copiedId, setCopiedId] = useState(null);
+ const [explainingId, setExplainingId] = useState(null);
+ const [insightErrors, setInsightErrors] = useState>({});
const [saved, setSaved] = useState(false);
const [isSample, setIsSample] = useState(false);
+ // Two readings of the same slices are on offer while we decide which one a
+ // card keeps; the switch is per page rather than per card so the comparison
+ // is between pages of cards rather than between neighbours.
+ const [chartKind, setChartKind] = useState("bar");
const updateFile = (kind: UploadKind, file?: File) => {
setFiles((current) => ({ ...current, [kind]: file }));
};
const validBudget = frameBudget.trim() !== "" && Number.isFinite(Number(frameBudget)) && Number(frameBudget) > 0;
+ // A model is not part of this: every card an analysis produces is measured,
+ // and the model is only ever asked to read one afterwards.
const isReady = Boolean(
- (profileType === "javascript" ? files.cpu : files.reactProfile) && modelStatus?.configured && (profileType !== "react" || validBudget),
+ (profileType === "javascript" ? files.cpu : files.reactProfile)
+ && (profileType !== "react" || validBudget),
);
const handleAnalyze = async () => {
@@ -565,11 +1243,6 @@ function InspectorApp() {
setErrorDetail(null);
return;
}
- if (!modelStatus?.configured) {
- setError("Choose a provider and model in Analysis settings first.");
- setErrorDetail(null);
- return;
- }
if (profileType === "react" && !validBudget) {
setError("Enter a finite positive commit budget in milliseconds.");
return;
@@ -589,9 +1262,13 @@ function InspectorApp() {
analysisId?: string | null; saved?: boolean;
totalMs?: number;
hotspots?: Hotspot[];
+ taskCards?: TaskCardSet;
+ cards?: ProfileCard[];
+ callCountIsExact?: boolean;
usage?: TokenUsage;
model?: AnalysisModel;
summary?: ReactSummary;
+ reactCards?: ReactCardSet;
issues?: ReactIssue[];
noIssue?: boolean;
frameBudgetMs?: number;
@@ -613,19 +1290,24 @@ function InspectorApp() {
setIsSample(false);
if (profileType === "react") {
- if (!Array.isArray(data.issues) || data.noIssue !== (data.issues.length === 0)) {
- setError("The server did not return a valid React issue report. Check the dev server logs.");
+ // The measured engine returns cards; the analyzer engine returns the
+ // issue list it always did. Either is valid, neither is optional.
+ if (!data.reactCards && (!Array.isArray(data.issues) || data.noIssue !== (data.issues.length === 0))) {
+ setError("The server did not return a valid React report. Check the dev server logs.");
setPhase("upload");
return;
}
- if (data.issues.length > 0 && !data.analysisId) {
+ if ((data.reactCards?.cards.length || data.issues?.length) && !data.analysisId) {
setError("The server did not return an analysis id. Check the dev server logs.");
setPhase("upload");
return;
}
setAnalysisId(data.analysisId ?? null);
setHotspots([]);
- setReactIssues(data.issues);
+ setTaskCards(null);
+ setCards([]);
+ setReactCards(data.reactCards ?? null);
+ setReactIssues(data.issues ?? []);
setAppliedBudget(data.frameBudgetMs ?? Number(frameBudget));
setReactSummary(data.summary ?? null);
setTotalMs(data.summary?.totalCommitRenderDurationMs ?? 0);
@@ -638,6 +1320,9 @@ function InspectorApp() {
analysisId: data.analysisId ?? "",
totalMs: data.totalMs ?? 0,
hotspots: data.hotspots ?? [],
+ taskCards: data.taskCards,
+ cards: data.cards ?? [],
+ callCountIsExact: data.callCountIsExact === true,
usage: data.usage,
model: data.model,
};
@@ -649,7 +1334,11 @@ function InspectorApp() {
setAnalysisId(result.analysisId);
setTotalMs(result.totalMs);
setHotspots(result.hotspots);
+ setTaskCards(result.taskCards ?? null);
+ setCards(result.cards ?? []);
+ setCallCountIsExact(result.callCountIsExact === true);
setReactSummary(null);
+ setReactCards(null);
setPhase("results");
void refreshHistory();
} catch (err) {
@@ -679,6 +1368,132 @@ function InspectorApp() {
setTimeout(() => setCopiedId((current) => (current === id ? null : current)), 2000);
};
+ /**
+ * Open one card's subtree in its own tab. The card already carries everything
+ * the view needs, so this is a storage write and a `window.open` — no new
+ * endpoint, and nothing extra kept on the server.
+ */
+ const exploreCard = (card: ProfileCard) => {
+ if (!analysisId) return;
+ const href = storeCardForExplore({
+ analysisId,
+ totalMs,
+ callCountIsExact,
+ card,
+ });
+ window.open(href, "_blank", "noopener");
+ };
+
+ /**
+ * Open one task in its own tab. With a frame id the view opens focused on
+ * that frame; without one it opens on the whole task, which is what a click
+ * on the card header means.
+ */
+ const exploreTask = (card: TaskCard, focusNodeId?: string) => {
+ if (!analysisId) return;
+ window.open(taskExploreHref(analysisId, card.taskIndex, focusNodeId), "_blank", "noopener");
+ };
+
+ /**
+ * Open one React commit in its own tab.
+ *
+ * The recording is fetched there rather than handed over, like a task: the
+ * commit trees live on the analysis record and the drill-down opens on the
+ * whole strip, not on this commit alone.
+ */
+ const exploreReactCommit = (card: ReactCard) => {
+ if (!analysisId) return;
+ window.open(reactExploreHref(analysisId, card.rootId, card.commitIndex), "_blank", "noopener");
+ };
+
+ /**
+ * Ask a model to read one React commit.
+ *
+ * The same button, the same storage and the same discard of a stale hand-off
+ * as `explainTask` below. What the model is shown differs — a commit has no
+ * timeline and no call tree, only the components and the recorded reasons —
+ * and `react-insight.ts` is where that is said.
+ */
+ const explainReactCard = async (card: ReactCard) => {
+ if (!analysisId || explainingId) return;
+ setExplainingId(card.id);
+ setInsightErrors((current) => {
+ if (!(card.id in current)) return current;
+ const next = { ...current };
+ delete next[card.id];
+ return next;
+ });
+ try {
+ const response = await fetch("/api/react-insight", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ analysisId, cardId: card.id }),
+ });
+ const data = (await response.json().catch(() => ({}))) as { error?: string; insight?: ReactCard["insight"]; usage?: TokenUsage; model?: AnalysisModel };
+ if (!response.ok || !data.insight) throw new Error(data.error ?? `Could not read this commit (${response.status}).`);
+ const insight = data.insight;
+ setReactCards((current) => current && ({
+ ...current,
+ cards: current.cards.map((entry) => (entry.id === card.id ? { ...entry, insight } : entry)),
+ }));
+ setPrompts((current) => {
+ if (!(card.id in current)) return current;
+ const next = { ...current };
+ delete next[card.id];
+ return next;
+ });
+ if (data.usage) setAnalyzerUsage(data.usage);
+ if (data.model) setAnalysisModel(data.model);
+ } catch (err) {
+ setInsightErrors((current) => ({ ...current, [card.id]: errorMessageFrom(err) }));
+ } finally {
+ setExplainingId((current) => (current === card.id ? null : current));
+ }
+ };
+
+ /**
+ * Ask a model to read one task. The server stores what comes back on the
+ * analysis, so the card is updated in place rather than re-fetched, and a
+ * hand-off already copied for this task is dropped — the next one carries
+ * the inference the server has just cached against it.
+ */
+ const explainTask = async (card: TaskCard) => {
+ if (!analysisId || explainingId) return;
+ setExplainingId(card.id);
+ setInsightErrors((current) => {
+ if (!(card.id in current)) return current;
+ const next = { ...current };
+ delete next[card.id];
+ return next;
+ });
+ try {
+ const response = await fetch("/api/task-insight", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ analysisId, cardId: card.id }),
+ });
+ const data = (await response.json().catch(() => ({}))) as { error?: string; insight?: TaskCard["insight"]; usage?: TokenUsage; model?: AnalysisModel };
+ if (!response.ok || !data.insight) throw new Error(data.error ?? `Could not read this task (${response.status}).`);
+ const insight = data.insight;
+ setTaskCards((current) => current && ({
+ ...current,
+ cards: current.cards.map((entry) => (entry.id === card.id ? { ...entry, insight } : entry)),
+ }));
+ setPrompts((current) => {
+ if (!(card.id in current)) return current;
+ const next = { ...current };
+ delete next[card.id];
+ return next;
+ });
+ if (data.usage) setAnalyzerUsage(data.usage);
+ if (data.model) setAnalysisModel(data.model);
+ } catch (err) {
+ setInsightErrors((current) => ({ ...current, [card.id]: errorMessageFrom(err) }));
+ } finally {
+ setExplainingId((current) => (current === card.id ? null : current));
+ }
+ };
+
const copyHandoff = async (id: string) => {
if (!analysisId || promptLoadingId) return;
setPromptErrors((current) => {
@@ -724,7 +1539,11 @@ function InspectorApp() {
setAnalysisId(null);
setAnalyzedType("javascript");
setHotspots([]);
+ setTaskCards(null);
+ setCards([]);
+ setCallCountIsExact(false);
setReactSummary(null);
+ setReactCards(null);
setReactIssues([]);
setPrompts({});
setAnalyzerUsage(null);
@@ -732,6 +1551,8 @@ function InspectorApp() {
setPromptLoadingId(null);
setPromptErrors({});
setCopiedId(null);
+ setExplainingId(null);
+ setInsightErrors({});
setSaved(false);
setIsSample(false);
};
@@ -746,7 +1567,8 @@ function InspectorApp() {
if (!response.ok) return;
const { analysis } = await response.json() as { analysis: SavedAnalysis };
setAnalysisId(analysis.id); setAnalyzedType(analysis.profileType === "react" ? "react" : "javascript");
- setTotalMs(analysis.totalMs); setHotspots(analysis.hotspots ?? []); setReactIssues(analysis.reactIssues ?? []);
+ setTotalMs(analysis.totalMs); setHotspots(analysis.hotspots ?? []); setTaskCards(analysis.taskCards ?? null); setCards(analysis.cards ?? []);
+ setCallCountIsExact(analysis.callCountIsExact === true); setReactCards(analysis.reactCards ?? null); setReactIssues(analysis.reactIssues ?? []);
setPrompts(analysis.prompts ?? {}); setAnalyzerUsage(analysis.usage ?? null); setAnalysisModel(analysis.model ?? null);
setReactSummary(null); setSaved(true); setPhase("results"); setHistoryOpen(false);
setIsSample(false);
@@ -872,7 +1694,8 @@ function InspectorApp() {
if (!response.ok) return;
const { analysis } = await response.json() as { analysis: SavedAnalysis };
setAnalysisId(analysis.id); setAnalyzedType("javascript"); setTotalMs(analysis.totalMs);
- setHotspots(analysis.hotspots); setReactIssues([]); setReactSummary(null);
+ setHotspots(analysis.hotspots); setTaskCards(analysis.taskCards ?? null); setCards(analysis.cards ?? []);
+ setCallCountIsExact(analysis.callCountIsExact === true); setReactCards(null); setReactIssues([]); setReactSummary(null);
setPrompts({}); setAnalyzerUsage(analysis.usage); setAnalysisModel(analysis.model ?? null); setSaved(true); setIsSample(true); setPhase("results");
};
const loadReactSample = async () => {
@@ -882,11 +1705,10 @@ function InspectorApp() {
if (!response.ok) return;
const { analysis, summary } = await response.json() as { analysis: SavedAnalysis; summary: ReactSummary & { frameBudgetMs: number } };
setAnalysisId(analysis.id); setAnalyzedType("react"); setTotalMs(analysis.totalMs);
- setHotspots([]); setReactIssues(analysis.reactIssues); setReactSummary(summary); setAppliedBudget(summary.frameBudgetMs);
+ setHotspots([]); setTaskCards(null); setCards([]); setReactCards(analysis.reactCards ?? null); setReactIssues(analysis.reactIssues); setReactSummary(summary); setAppliedBudget(summary.frameBudgetMs);
setPrompts({}); setAnalyzerUsage(analysis.usage); setAnalysisModel(analysis.model ?? null); setSaved(true); setIsSample(true); setPhase("results");
};
- const reactIssueCards = reactIssues.slice(0, MAX_RESULT_CARDS);
const analyzing = phase === "analyzing";
return (
@@ -1052,25 +1874,7 @@ function InspectorApp() {
{(phase === "upload" || phase === "analyzing") && (
<>
{showWelcome ? (
- <>
-
-
-
Find the code that makes your app feel slow
-
Turn profiler traces into a focused list of bottlenecks
-
-
-
-
React
-
JavaScript
-
React Native
-
-
-
-
-
- >
+
) : (
<>
@@ -1084,7 +1888,7 @@ function InspectorApp() {
@@ -1115,17 +1919,10 @@ function InspectorApp() {
disabled={analyzing}
aria-invalid={!validBudget} aria-describedby="react-budget-help"
onChange={event => setFrameBudget(event.target.value)} />
- Defaults to 16 ms. Use a lower budget, such as 8.33 ms, for a higher refresh-rate target.
+ Defaults to 16 ms.