From 5f201f93db36e567f171fa8c606ddb75f7edefc1 Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Sat, 2 May 2026 18:11:01 -0600 Subject: [PATCH] feat(studio): diagnostics polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small improvements that make Studio less mysterious when things go wrong. 1. enrichError(msg) — when a connection fails, the toast/status now includes a one-line actionable hint. Today's errors are honest ("iproxy not found") but leave the user to look up the fix; the enriched output ("Fix: brew install libimobiledevice") closes that gap. Patterns covered: iproxy/idevicesyslog missing, adb missing, token timeout/not found, missing PROBE_AGENT define, connection refused/reset. 2. Status indicator tooltip — the dot + status text now have a title attribute showing device id, transport (USB / WiFi), and last error. Hover over the indicator to see what Studio is actually connected to without taking up toolbar space. 3. Inspector search box — text input in the inspector pane title scrolls the
 to the first line containing the query.
   MutationObserver re-anchors the scroll on each live frame from the
   device stream, so the matched line doesn't jump back to the top
   when the tree refreshes.

Pure frontend change.
---
 CHANGELOG.md                  |   1 +
 studio/frontend/index.html    |   8 +++
 studio/frontend/src/main.ts   | 101 +++++++++++++++++++++++++++++++---
 studio/frontend/src/style.css |  11 ++++
 4 files changed, 114 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b82da52..9e031d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
 ### Added
 - **Studio: WiFi token memory** — Studio now remembers the agent token per discovered device (keyed by Bonjour instance name) in localStorage. After a successful WiFi connect, subsequent mDNS-discovered sessions for that device prefill the token automatically and show a "🔑 saved" tag. Each remembered device gets a small "✕" button to forget the token.
 - **Studio: workspace settings overlay** — new ⚙ button in the toolbar opens a small form for `agent.port`, `defaults.timeout`, `device.ios_device_id`, and `device.android_device_id`. Save writes back to `probe.yaml` in the active workspace. Other keys you've set in `probe.yaml` are preserved on save (loaded as a raw map; only the four managed keys are mutated).
+- **Studio: diagnostics polish** — connection error toasts now include actionable hints (e.g. `Fix: brew install libimobiledevice` for missing iproxy/idevicesyslog, `Fix: rebuild your Flutter app with --dart-define=PROBE_AGENT=true` for missing token). Status indicator gets a tooltip showing device id + transport. Inspector pane gets a search box that scrolls to the first matching line and re-anchors on each live frame.
 
 ## [0.7.0] - 2026-05-02
 
diff --git a/studio/frontend/index.html b/studio/frontend/index.html
index 16c1ff0..be7a15d 100644
--- a/studio/frontend/index.html
+++ b/studio/frontend/index.html
@@ -72,6 +72,14 @@
       
Inspector +
Connect to load the widget tree.
diff --git a/studio/frontend/src/main.ts b/studio/frontend/src/main.ts index fa82e01..f5389fc 100644 --- a/studio/frontend/src/main.ts +++ b/studio/frontend/src/main.ts @@ -512,12 +512,17 @@ $("btn-wifi-connect").addEventListener("click", async () => { try { await ConnectWiFi(wifiSelected.host, wifiSelected.port, token); rememberToken(wifiSelected.name, token); - setStatus("connected", `Connected · ${wifiSelected.name}`); + setStatus( + "connected", + `Connected · ${wifiSelected.name}`, + `${wifiSelected.name} (${wifiSelected.host}:${wifiSelected.port}) · WiFi transport` + ); closeWiFiOverlay(); startStream(); } catch (err) { - setStatus("error", `WiFi connect failed: ${err}`); - toast(`Connect failed: ${err}`, "error"); + const detail = enrichError(err); + setStatus("error", "WiFi connect failed", detail); + toast(`Connect failed: ${detail}`, "error", 8000); } }); @@ -525,11 +530,20 @@ $("btn-wifi-connect").addEventListener("click", async () => { let connected = false; -function setStatus(state: "disconnected" | "connecting" | "connected" | "error", text: string) { +function setStatus( + state: "disconnected" | "connecting" | "connected" | "error", + text: string, + tooltip?: string +) { const dot = $("status-dot"); dot.classList.remove("status-disconnected", "status-connecting", "status-connected", "status-error"); dot.classList.add(`status-${state}`); $("status-text").textContent = text; + // Mirror the human-readable text in title attributes so a hover gives + // device id / transport / last error without taking up toolbar space. + const tip = tooltip ?? text; + dot.title = tip; + $("status-text").title = tip; connected = state === "connected"; updateRunButton(); const inspectorStatus = document.getElementById("inspector-status"); @@ -538,6 +552,33 @@ function setStatus(state: "disconnected" | "connecting" | "connected" | "error", } } +// enrichError maps known error fragments to a one-line actionable hint. +// Connection failures often come from missing host tools (iproxy, adb) or +// the app not being built with the agent flag — surfacing the fix inline +// beats sending the user to a wiki page. +function enrichError(raw: unknown): string { + const msg = String(raw); + if (/iproxy/i.test(msg)) { + return `${msg}\n\nFix: brew install libimobiledevice`; + } + if (/idevicesyslog/i.test(msg)) { + return `${msg}\n\nFix: brew install libimobiledevice (idevicesyslog ships with it)`; + } + if (/adb/i.test(msg) && /not found|not in PATH/i.test(msg)) { + return `${msg}\n\nFix: install Android SDK platform-tools and ensure adb is on your PATH`; + } + if (/token/i.test(msg) && /(not found|timeout)/i.test(msg)) { + return `${msg}\n\nFix: confirm your Flutter app is running with --dart-define=PROBE_AGENT=true`; + } + if (/PROBE_AGENT/.test(msg)) { + return `${msg}\n\nFix: rebuild your Flutter app with --dart-define=PROBE_AGENT=true`; + } + if (/refused|reset by peer|EOF/i.test(msg)) { + return `${msg}\n\nFix: the agent process likely isn't running. Start your Flutter app and try again.`; + } + return msg; +} + function updateRunButton() { ($("btn-run") as HTMLButtonElement).disabled = !connected || !currentPath; } @@ -640,12 +681,17 @@ $("btn-connect").addEventListener("click", async () => { setStatus("connecting", "connecting…"); try { const status = (await Connect(id)) as ConnectionStatus; - setStatus("connected", `${status.deviceName} · ${status.platform}`); + setStatus( + "connected", + `${status.deviceName} · ${status.platform}`, + `${status.deviceName} (${status.deviceId}) · ${status.platform} · USB transport` + ); startStream(); toast(`Connected to ${status.deviceName}`, "success"); } catch (err) { - setStatus("error", "connection failed"); - toast(`Connect failed: ${err}`, "error", 6000); + const detail = enrichError(err); + setStatus("error", "connection failed", detail); + toast(`Connect failed: ${detail}`, "error", 8000); setTimeout(() => setStatus("disconnected", "disconnected"), 2500); } }); @@ -664,6 +710,47 @@ EventsOn("connection:changed", (status: ConnectionStatus) => { // The inspector pane is updated live by the device-stream module — every // frame from the backend includes a widget tree dump. No manual refresh. +// Search input scrolls the
 to the first line containing the query.
+// Re-runs on every input AND every animation frame so live updates from
+// the device stream don't push the matched line off-screen. Cheap O(n)
+// over the visible widget tree text — acceptable for trees up to a few
+// thousand lines, which is the practical limit anyway.
+let inspectorQuery = "";
+
+function scrollInspectorToMatch() {
+  if (!inspectorQuery) return;
+  const pre = document.getElementById("inspector-content");
+  if (!pre) return;
+  const text = pre.textContent ?? "";
+  const idx = text.toLowerCase().indexOf(inspectorQuery.toLowerCase());
+  if (idx < 0) return;
+  // Approximate line number → scrollTop. The 
 uses a monospace font
+  // with consistent line height; this is precise enough to put the match
+  // near the top of the visible area.
+  const lineNumber = text.slice(0, idx).split("\n").length - 1;
+  const lineHeight = parseFloat(getComputedStyle(pre).lineHeight) || 14;
+  pre.scrollTop = Math.max(0, lineNumber * lineHeight - 8);
+}
+
+const inspectorSearch = document.getElementById("inspector-search") as HTMLInputElement | null;
+if (inspectorSearch) {
+  inspectorSearch.addEventListener("input", () => {
+    inspectorQuery = inspectorSearch.value;
+    scrollInspectorToMatch();
+  });
+  // Re-anchor on each frame the inspector content updates. The device-stream
+  // module replaces innerHTML wholesale per frame; without a re-scroll the
+  // matched line jumps back to the top.
+  const inspectorEl = document.getElementById("inspector-content");
+  if (inspectorEl) {
+    new MutationObserver(() => scrollInspectorToMatch()).observe(inspectorEl, {
+      childList: true,
+      characterData: true,
+      subtree: true,
+    });
+  }
+}
+
 // ---- Run ----------------------------------------------------------------
 
 initResults();
diff --git a/studio/frontend/src/style.css b/studio/frontend/src/style.css
index 69227c4..5ca40c0 100644
--- a/studio/frontend/src/style.css
+++ b/studio/frontend/src/style.css
@@ -404,6 +404,17 @@ body { display: flex; flex-direction: column; overflow: hidden; }
   font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 12px;
 }
 .settings-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
+
+/* Inspector search */
+.inspector-search {
+  flex: 1; max-width: 200px; margin-left: auto; margin-right: 8px;
+  padding: 2px 8px; box-sizing: border-box;
+  background: var(--bg-1); border: 1px solid var(--border-strong);
+  border-radius: var(--radius-sm); color: var(--fg-strong);
+  font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 11px;
+}
+.inspector-search:focus { outline: 1px solid var(--fg-dim); outline-offset: -1px; }
+
 kbd {
   display: inline-block;
   padding: 1px 6px;