Skip to content

Commit 4b29c85

Browse files
committed
fix: stabilize inspector and profiling surfaces
1 parent 829178f commit 4b29c85

10 files changed

Lines changed: 1740 additions & 485 deletions

File tree

client/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export interface WebKitTarget {
5757
id: string;
5858
appId: string;
5959
appName?: string | null;
60+
appActive?: boolean;
61+
pageActive?: boolean;
6062
pageId: number;
6163
title?: string | null;
6264
url?: string | null;

client/src/app/AppShell.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
116116
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
117117
const FLUTTER_INSPECTOR_MAX_DEPTH = 48;
118118
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
119+
const NOT_CONNECTED_MESSAGE = "Not connected";
119120
const LOCAL_STREAM_DEFAULTS: StreamConfig = {
120121
encoder: "auto",
121122
fps: 60,
@@ -692,6 +693,16 @@ export function AppShell({
692693
streamTransport,
693694
});
694695

696+
useEffect(() => {
697+
if (
698+
streamStatus.state !== "error" ||
699+
!isStreamProviderDisconnectError(streamStatus.error)
700+
) {
701+
return;
702+
}
703+
void refreshRef.current();
704+
}, [streamStatus.error, streamStatus.state]);
705+
695706
const updateStreamEncoder = useCallback((encoder: StreamEncoder) => {
696707
streamConfigUserTouchedRef.current = true;
697708
streamConfigUserChangeAtRef.current = Date.now();
@@ -1434,8 +1445,9 @@ export function AppShell({
14341445
pairingEnabled &&
14351446
listError === AUTH_REQUIRED_MESSAGE &&
14361447
!accessTokenFromLocation();
1437-
const visibleListError =
1438-
remoteStream && listError === AUTH_REQUIRED_MESSAGE
1448+
const visibleListError = providerDisconnected
1449+
? NOT_CONNECTED_MESSAGE
1450+
: remoteStream && listError === AUTH_REQUIRED_MESSAGE
14391451
? ""
14401452
: selectedSimulator
14411453
? friendlyClientError(listError)
@@ -1471,14 +1483,16 @@ export function AppShell({
14711483
streamStatusState: streamStatus.state,
14721484
});
14731485
const viewportStatusOverlayLabel =
1486+
(providerDisconnected ? NOT_CONNECTED_MESSAGE : "") ||
14741487
simulatorStatusOverlayLabel ||
14751488
streamStatusMessage ||
14761489
(browserFramePending ? "Stream connected, browser frame pending" : "") ||
14771490
(selectedSimulator ? visibleListError : "");
14781491
const viewportHasStreamError = Boolean(
1492+
providerDisconnected ||
14791493
streamStatus.state === "error" ||
1480-
visibleStreamError ||
1481-
(selectedSimulator && visibleListError),
1494+
visibleStreamError ||
1495+
(selectedSimulator && visibleListError),
14821496
);
14831497
const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`;
14841498
const chromeScreenRect = computeChromeScreenRect(
@@ -2436,6 +2450,14 @@ function friendlyStreamError(
24362450
return friendlyClientError(normalized);
24372451
}
24382452

2453+
function isStreamProviderDisconnectError(message: string | undefined): boolean {
2454+
const lower = message?.trim().toLowerCase() ?? "";
2455+
return (
2456+
lower.includes("websocket stream closed") ||
2457+
lower.includes("websocket stream failed")
2458+
);
2459+
}
2460+
24392461
function streamAgentStatusLabel({
24402462
hasFrame,
24412463
simulator,
@@ -2514,7 +2536,7 @@ function userFacingAccessibilityError(message: string): string {
25142536

25152537
const lower = normalized.toLowerCase();
25162538
if (isProviderDisconnected(normalized)) {
2517-
return "Not connected";
2539+
return NOT_CONNECTED_MESSAGE;
25182540
}
25192541
if (
25202542
lower.includes("no app inspector found") ||

client/src/features/accessibility/AccessibilityInspector.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,11 @@ export function AccessibilityInspector({
306306
</div>
307307
) : error ? (
308308
<div className="hierarchy-empty error">{error}</div>
309-
) : visibleItems.length === 0 && isLoading ? (
309+
) : visibleItems.length === 0 && isLoading && !source ? (
310310
<div className="hierarchy-empty">Reading accessibility tree...</div>
311311
) : visibleItems.length === 0 ? (
312312
<div className="hierarchy-empty">
313-
No accessibility snapshot yet.
313+
{emptyAccessibilityMessage(source)}
314314
</div>
315315
) : (
316316
visibleItems.map((item) => {
@@ -840,6 +840,15 @@ function sourceLabel(source: AccessibilitySource): string {
840840
return source === "in-app-inspector" ? "UIKit" : "Native AX";
841841
}
842842

843+
function emptyAccessibilityMessage(
844+
source: AccessibilityTreeResponse["source"] | "",
845+
): string {
846+
if (source === "native-ax") {
847+
return "No native accessibility elements available.";
848+
}
849+
return "No accessibility snapshot available.";
850+
}
851+
843852
function objectClassName(value: Record<string, unknown> | null | undefined) {
844853
const className = value?.className;
845854
return typeof className === "string" ? className : "";

client/src/features/accessibility/PerformancePanel.tsx

Lines changed: 107 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useEffect, useMemo, useState } from "react";
2-
import type { CSSProperties, PointerEvent } from "react";
1+
import { useEffect, useId, useMemo, useState } from "react";
2+
import type { CSSProperties, ChangeEvent, PointerEvent } from "react";
33

44
import {
55
fetchSimulatorPerformance,
@@ -97,15 +97,37 @@ export function PerformancePanel({
9797
visible,
9898
]);
9999

100+
const processes = performance?.processes ?? [];
101+
const selectedPidValue = selectedPid ?? performance?.selectedPid ?? null;
102+
const selectedPidInList =
103+
selectedPidValue == null ||
104+
processes.some((process) => process.pid === selectedPidValue);
100105
const current = performance?.current ?? null;
101106
const selectedProcess = useMemo(
102-
() =>
103-
performance?.processes.find(
104-
(process) => process.pid === (selectedPid ?? performance.selectedPid),
105-
) ?? null,
106-
[performance, selectedPid],
107+
() => processes.find((process) => process.pid === selectedPidValue) ?? null,
108+
[processes, selectedPidValue],
107109
);
108110

111+
function selectProcess(event: ChangeEvent<HTMLSelectElement>) {
112+
const nextPid = Number(event.currentTarget.value);
113+
if (!Number.isInteger(nextPid)) {
114+
return;
115+
}
116+
setFollowForeground(false);
117+
setSelectedPid(nextPid);
118+
setSample(null);
119+
}
120+
121+
function followFrontmostProcess() {
122+
setFollowForeground(true);
123+
setSelectedPid(
124+
performance?.foregroundProcess?.processIdentifier ??
125+
performance?.selectedPid ??
126+
null,
127+
);
128+
setSample(null);
129+
}
130+
109131
async function runSample() {
110132
const pid = selectedPid ?? performance?.selectedPid ?? null;
111133
if (!udid || pid == null) {
@@ -133,27 +155,36 @@ export function PerformancePanel({
133155

134156
return (
135157
<div className="performance-panel">
136-
<div className="performance-process-list">
137-
{performance?.processes.length ? (
138-
performance.processes.map((process) => (
139-
<ProcessButton
140-
key={process.pid}
141-
onSelect={() => {
142-
setSelectedPid(process.pid);
143-
setFollowForeground(process.isForeground);
144-
setSample(null);
145-
}}
146-
process={process}
147-
selected={
148-
process.pid === (selectedPid ?? performance.selectedPid)
149-
}
150-
/>
151-
))
152-
) : (
153-
<div className="performance-empty compact">
154-
{error || "Waiting for an app process."}
155-
</div>
156-
)}
158+
<div className="performance-target-bar">
159+
<div className="performance-process-select-wrap">
160+
<select
161+
aria-label="Performance process"
162+
className="performance-process-select"
163+
disabled={!processes.length}
164+
onChange={selectProcess}
165+
value={selectedPidValue == null ? "" : String(selectedPidValue)}
166+
>
167+
{processes.length ? null : (
168+
<option value="">Waiting for an app process</option>
169+
)}
170+
{selectedPidValue != null && !selectedPidInList ? (
171+
<option value={selectedPidValue}>PID {selectedPidValue}</option>
172+
) : null}
173+
{processes.map((process) => (
174+
<option key={process.pid} value={process.pid}>
175+
{processOptionLabel(process)}
176+
</option>
177+
))}
178+
</select>
179+
</div>
180+
<button
181+
className={`performance-follow-button ${followForeground ? "active" : ""}`}
182+
disabled={!performance?.foregroundProcess}
183+
onClick={followFrontmostProcess}
184+
type="button"
185+
>
186+
Follow Frontmost
187+
</button>
157188
</div>
158189

159190
{error && performance?.processes.length ? (
@@ -295,34 +326,22 @@ export function PerformancePanel({
295326
)}
296327
</section>
297328
</>
298-
) : null}
329+
) : (
330+
<div className="performance-empty compact">
331+
{error || "Collecting performance metrics."}
332+
</div>
333+
)}
299334
</div>
300335
);
301336
}
302337

303-
function ProcessButton({
304-
onSelect,
305-
process,
306-
selected,
307-
}: {
308-
onSelect: () => void;
309-
process: PerformanceProcess;
310-
selected: boolean;
311-
}) {
312-
return (
313-
<button
314-
className={`performance-process ${selected ? "selected" : ""}`}
315-
onClick={onSelect}
316-
title={process.command}
317-
type="button"
318-
>
319-
<span className="performance-process-name">{process.process}</span>
320-
<span className="performance-process-meta">
321-
{process.pid} / {process.role}
322-
{process.isForeground ? " / frontmost" : ""}
323-
</span>
324-
</button>
325-
);
338+
function processOptionLabel(process: PerformanceProcess): string {
339+
const name = process.appName || process.process;
340+
const parts = [`${name} (${process.pid})`, process.role];
341+
if (process.isForeground) {
342+
parts.push("frontmost");
343+
}
344+
return parts.join(" / ");
326345
}
327346

328347
function Metric({ label, value }: { label: string; value: string }) {
@@ -345,6 +364,7 @@ function Timeline({
345364
value: (sample: PerformanceSample) => number;
346365
valueLabel: (value: number | null | undefined) => string;
347366
}) {
367+
const gradientId = useId().replace(/:/g, "");
348368
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
349369
const values = samples.map(value);
350370
const latest = values.at(-1) ?? 0;
@@ -353,9 +373,13 @@ function Timeline({
353373
x: values.length <= 1 ? 0 : (index / (values.length - 1)) * 100,
354374
y: 42 - (Math.max(0, item) / max) * 36,
355375
}));
356-
const points = coordinates
357-
.map((point) => `${round(point.x)},${round(point.y)}`)
376+
const linePath = coordinates
377+
.map(
378+
(point, index) =>
379+
`${index === 0 ? "M" : "L"} ${round(point.x)} ${round(point.y)}`,
380+
)
358381
.join(" ");
382+
const areaPath = linePath ? `${linePath} L 100 42 L 0 42 Z` : "";
359383
const activeIndex =
360384
hoverIndex == null || hoverIndex >= samples.length ? null : hoverIndex;
361385
const activePoint = activeIndex == null ? null : coordinates[activeIndex];
@@ -385,8 +409,32 @@ function Timeline({
385409
preserveAspectRatio="none"
386410
viewBox="0 0 100 44"
387411
>
388-
<line x1="0" x2="100" y1="42" y2="42" />
389-
{points ? <polyline points={points} /> : null}
412+
<defs>
413+
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
414+
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.26" />
415+
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0.02" />
416+
</linearGradient>
417+
</defs>
418+
{[6, 18, 30, 42].map((y) => (
419+
<line
420+
className="performance-chart-grid"
421+
key={y}
422+
x1="0"
423+
x2="100"
424+
y1={y}
425+
y2={y}
426+
/>
427+
))}
428+
{areaPath ? (
429+
<path
430+
className="performance-chart-area"
431+
d={areaPath}
432+
fill={`url(#${gradientId})`}
433+
/>
434+
) : null}
435+
{linePath ? (
436+
<path className="performance-chart-line" d={linePath} />
437+
) : null}
390438
{activePoint ? (
391439
<>
392440
<line
@@ -456,13 +504,13 @@ function formatRate(value: number | null | undefined): string {
456504

457505
function hangLabel(state: string): string {
458506
if (state === "busy") {
459-
return "Busy";
507+
return "Potential hang";
460508
}
461509
if (state === "quiet") {
462-
return "Quiet";
510+
return "No frame updates";
463511
}
464512
if (state === "responsive") {
465-
return "Responsive";
513+
return "Rendering OK";
466514
}
467515
return "Unknown";
468516
}

0 commit comments

Comments
 (0)