Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/telemetry.md

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions packages/mcp-app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ export function startMcpApp(options: { workspace?: WorkspaceOptions } = {}) {
});
else if (view === "comparison") startCardApp({
name: "mobile-dev-comparison", surface: "comparison", fallback: "This comparison could not render. Reopen it to try again.",
create: app => new ComparisonController(app, { workspace }),
create: app => {
const extensions = new OpenAIExtensions(app);
return new ComparisonController(app, { attachments: () => extensions.modelContext, workspace });
},
render: controller => <ComparisonCard controller={controller} />,
});
else if (view === "tracesift") startCardApp({
name: "mobile-dev-tracesift", surface: "tracesift", fallback: "This TraceSift analysis could not render. Reopen it to try again.",
create: app => new TraceSiftController(app),
create: app => {
const extensions = new OpenAIExtensions(app);
return new TraceSiftController(app, { attachments: () => extensions.modelContext });
},
render: controller => <TraceSiftCards controller={controller} />,
hostChanged: (controller, host) => {
const viewId = host?.toolInfo?.id;
Expand All @@ -44,8 +50,6 @@ export function startMcpApp(options: { workspace?: WorkspaceOptions } = {}) {
const parsed = traceSiftProfileInputSchema.safeParse(input);
if (parsed.success) controller.setProfileInput(parsed.data.path, parsed.data.profileType);
},
// The inline card investigates too, so it needs the same project the panel saved.
connected: controller => void controller.loadProject(),
});
// Chats keep devices cards from releases before device choice moved to the host's own form.
else if (view === "devices") startRetiredCard("This device list came from an earlier version of Mobile Dev. Ask again to choose a device.");
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp-app/test/card-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ for (const view of ["recording", "comparison", "tracesift", "devices"] as const)
// React commits the first render on its own schedule, which can follow the theme.
await until(() => Boolean(window.document.getElementById("root")?.childElementCount), "render into the page");
if (view === "devices") assert.match(window.document.getElementById("root")!.textContent ?? "", /earlier version of Mobile Dev/);
if (view === "tracesift") await until(() => calls.includes("mobile_get_tracesift_project"), "load the saved TraceSift project");
// The card asks for no project folder; Investigate hands the finding to the chat as is.
if (view === "tracesift") assert.equal(calls.includes("mobile_get_tracesift_project"), false);
send({ id: "teardown", method: "ui/resource-teardown", params: {} });
await until(() => tornDown, "acknowledge teardown");
});
5 changes: 5 additions & 0 deletions packages/shared/src/tracesift-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
* `format.ts`, `source-location.ts` and `frame-location.ts` so both cards print the same.
*/

/** A finding's name: the last frame of its path, which is the code it is about. */
export function findingName(card: { pathline?: string; headline: string }) {
return card.pathline?.split(" › ").at(-1) || card.headline;
}

/** Durations as a reader scans them: whole milliseconds until seconds are clearer. */
export function formatMs(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "0 ms";
Expand Down
32 changes: 25 additions & 7 deletions packages/ui/src/comparison-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { chatMessage } from "./chat-message.ts";
import { cardsCallTools } from "./host-bridge.ts";
import type { HostBridge } from "./host-bridge.ts";
import { attachReference, cardsCallTools } from "./host-bridge.ts";
import type { HostAttachmentsSource, HostBridge } from "./host-bridge.ts";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { comparisonDuration, comparisonReferenceSchema, comparisonSchema } from "@mobile-dev/shared/performance-comparison.ts";
import type { PerformanceComparison } from "@mobile-dev/shared/performance-comparison.ts";
Expand All @@ -24,15 +24,18 @@ type ComparisonState = {

export class ComparisonController {
private readonly app: HostBridge;
private readonly attachments: HostAttachmentsSource;
private state: ComparisonState;
private listeners = new Set<() => void>();
private disposed = false;
private readyAt = performance.now();
/** The full recordings being read; a newer comparison replaces them. */
private loading?: AbortController;

constructor(app: HostBridge, { workspace = false }: { workspace?: boolean } = {}) {
/** `attachments` reads the host's composer pills, where it has them; `workspace` says the server has Mobile Dev's workspace. */
constructor(app: HostBridge, { attachments = () => undefined, workspace = false }: { attachments?: HostAttachmentsSource; workspace?: boolean } = {}) {
this.app = app;
this.attachments = attachments;
this.state = { hiddenIds: new Set(), canMessage: false, workspace, busy: false, error: "" };
}
subscribe = (listener: () => void) => {
Expand Down Expand Up @@ -149,17 +152,32 @@ export class ComparisonController {
const reference = JSON.stringify({ recordingIds, title: comparison.title, ...(range ? { range } : {}) });
const interval = range ? `${range.start}–${range.end}s` : "the entire runs";
const prompt = `Compare CPU, memory, and device-wide Display FPS for ${interval}, aligned at recording start. Read the original runs and summaries with mobile_compare_performance_recordings using ${reference}. Account for different durations, devices, apps and memory definitions. Distinguish measurements from hypotheses about their cause; device-wide display data cannot attribute a slowdown to one app.`;
const message = chatMessage(this.app, prompt);
const startedAt = performance.now();
let sending = false;
try {
const result = await this.app.sendMessage(message);
// Hosts with composer pills carry the reference there, and the message stays one line.
const modelContext = this.attachments();
if (modelContext) {
const contextStartedAt = performance.now();
try {
await attachReference(modelContext, {
marker: "mobile-dev/comparisonReference", title: range ? `${comparison.title} · ${interval}` : comparison.title, text: prompt,
structuredContent: { selectedComparison: { recordingIds, ...(range ? { range } : {}) } },
});
} finally { recordUiTiming("ui.comparison.context_attach", performance.now() - contextStartedAt); }
}
if (this.disposed) return;
const message = chatMessage(this.app, modelContext ? (range ? "Compare this selected range." : "Compare these runs.") : prompt);
const startedAt = performance.now();
sending = true;
const result = await this.app.sendMessage(message, { timeout: 5000, maxTotalTimeout: 5000 });
if (result.isError) throw new Error("The host could not send this comparison to chat.");
countUiEvent("ui.comparison.ask");
const elapsed = performance.now() - startedAt;
recordUiTiming("ui.comparison.message_ack", elapsed);
} catch (error) {
captureUiError(error, "comparison.ask");
const text = error instanceof Error ? error.message : "Could not send this comparison to chat.";
const timedOut = sending && typeof error === "object" && error !== null && "code" in error && error.code === -32001;
const text = timedOut ? "The host did not confirm delivery. Check the chat before retrying to avoid sending the question twice." : error instanceof Error ? error.message : "Could not send this comparison to chat.";
this.update({ error: text });
} finally { this.update({ busy: false }); }
}
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/comparison-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,57 @@ export function createComparisonSeries(recordings: PerformanceRecording[], metri
}
return rows;
}

export type SharpMetric = "cpu" | "memory" | "fps";
/** One stretch of a run where a measure moved sharply, with the readings across it. */
export type SharpChange = { run: number; metric: SharpMetric; start: number; end: number; values: number[] };

// Fixed per-second steps that count as sharp. A share of the largest change would flag a
// quiet run's noise as loudly as another run's spike.
export const SHARP_CHANGE: Record<SharpMetric, number> = { cpu: 10, memory: 50, fps: 15 };

type Reading = { time: number; interval: number; value: number | null };

function readings(recording: PerformanceRecording, metric: SharpMetric): Reading[] {
if (metric === "fps") return recording.fps.samples.map(sample => ({ time: sample.time, interval: sample.interval, value: sample.fps }));
return recording.samples.map(sample => ({
time: sample.time,
interval: sample.interval,
value: metric === "cpu" ? sample.cpuPercent : sample.memoryBytes === null ? null : sample.memoryBytes / 1048576,
}));
}

export function findSharpChanges(recordings: PerformanceRecording[]): SharpChange[][] {
return recordings.map((recording, run) => {
const changes: SharpChange[] = [];
for (const metric of ["cpu", "memory", "fps"] as const) {
let previous: { time: number; value: number } | undefined;
let current: SharpChange | undefined;
for (const reading of readings(recording, metric)) {
if (reading.value === null || reading.time > recording.durationSeconds) {
previous = undefined;
current = undefined;
continue;
}
if (previous !== undefined) {
const elapsed = reading.time - previous.time;
// A gap is missing readings, not a change: the series use the same tolerance.
const continuous = elapsed > 0 && elapsed <= reading.interval * 1.5;
// Readings come about a second apart; a slower cadence needs a larger step.
const seconds = Math.max(1, Math.round(elapsed));
const sharp = continuous && Math.abs(reading.value - previous.value) >= SHARP_CHANGE[metric] * seconds;
if (sharp && current?.end === previous.time) {
current.end = reading.time;
current.values.push(reading.value);
} else if (sharp) {
current = { run, metric, start: previous.time, end: reading.time, values: [previous.value, reading.value] };
changes.push(current);
} else current = undefined;
}
previous = { time: reading.time, value: reading.value };
}
}
changes.sort((left, right) => left.start - right.start);
return changes;
});
}
25 changes: 25 additions & 0 deletions packages/ui/src/components/card-parts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Pieces the inline cards share, so every card explains itself and switches views the same way.

import type { ReactNode } from "react";
import { InfoIcon } from "lucide-react";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";

/** The ⓘ beside a heading: the explanation stays one click away instead of under the chart. */
export function InfoPopover({ label, children }: { label: string; children: ReactNode }) {
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon-xs" className="recording-info" aria-label={`About ${label}`} title={`About ${label}`}><InfoIcon /></Button></PopoverTrigger>
<PopoverContent className="recording-info-content" side="bottom" align="start" aria-label={`About ${label}`}>{children}</PopoverContent>
</Popover>;
}

/** Two or three mutually exclusive views of the same data, such as Bar and Treemap. */
export function SegmentedControl<T extends string>({ label, options, value, onChange }: {
label: string;
options: readonly { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
}) {
return <div className="segmented-control" role="group" aria-label={label}>
{options.map(option => <button key={option.value} type="button" aria-pressed={option.value === value} onClick={() => onChange(option.value)}>{option.label}</button>)}
</div>;
}
Loading
Loading