From e6c884a0cd4ac79878928cd668bbd731343205f2 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 21:03:57 -0400 Subject: [PATCH] app: fixture-mode session screen and a "Run benchmark" control BenchFixture.kt/BenchNetwork.kt fake the backend for the bench build: a URLStreamHandlerFactory installed only under BuildConfig.FIXTURE_MODE answers TranscriptSource/EventStream's requests from an in-memory copy of the bundled fixture instead of opening a socket, so the fold, the paging and uniqueItems under test are the screen's real ones rather than a shortcut built for this. MainActivity opens straight onto that session when FIXTURE_MODE is set, with no enrollment and no permission prompts. BenchRun.kt drives the same scroll loop and streaming phase transcript-bench.sh/stream-bench.sh drive over ui-trace, but in-process (24 swipes through the real LazyListState, then 400 fixture events appended at 20/s through the real live-fold path), and adds process CPU time, peak RSS and battery current to the render report -- "unavailable" rather than a fabricated number where the device can't answer. "Run benchmark" sits beside the existing "Copy" in session settings, found by that exact label the way every other control here is (SessionSettingsDialog's onRunBenchmark, null on every build but bench). debugReport gained an optional `extra` section for this; empty and invisible on every other build's report. Co-Authored-By: Claude Fable 5.1 --- .../kotlin/com/example/aiapp/BenchFixture.kt | 108 +++++++++++ .../kotlin/com/example/aiapp/BenchNetwork.kt | 181 ++++++++++++++++++ .../main/kotlin/com/example/aiapp/BenchRun.kt | 143 ++++++++++++++ .../kotlin/com/example/aiapp/DebugStats.kt | 12 ++ .../kotlin/com/example/aiapp/MainActivity.kt | 49 +++++ .../kotlin/com/example/aiapp/SessionScreen.kt | 23 ++- .../example/aiapp/SessionSettingsDialog.kt | 22 +++ 7 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt new file mode 100644 index 0000000..c1802f1 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt @@ -0,0 +1,108 @@ +package com.example.aiapp + +import android.content.Context +import java.util.concurrent.CopyOnWriteArrayList + +/** + * P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process + * fake of the backend, so the `bench` build type can drive a real session screen -- the real + * [TranscriptSource], the real fold, the real paging -- with no server and no network permission. + * + * Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else + * in this build compiles it in but never calls it, since Kotlin has no per-build-type source set + * that both [MainActivity] (which every variant compiles) and this can share without one. + * + * The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through + * ordinary `java.net.URL`, exactly as they would talk to a real server. A + * [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every + * `https://` connection to that host and answers from this object's in-memory event log instead of + * opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen, + * the fold, uniqueItems) never learns the difference. + */ +object BenchFixture { + const val FIXTURE_HOST = "bench.fixture.invalid" + const val FIXTURE_PORT = 1 + + /** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */ + private const val BACKLOG_COUNT = 3200 + + val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench") + + /** The session id every bench run opens; nothing else in this build ever mints one. */ + const val SESSION_ID = "bench-fixture-session" + + /** + * The whole transcript, seq order, growing as [pushLive] is called during the streaming phase. + * Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a + * live frame agree on what has "already happened" -- the same thing a real server's own + * transcript file guarantees. + */ + private val log = CopyOnWriteArrayList>() + + /** The events not yet appended to [log] -- the streaming phase's own source. */ + private var streamTail: List> = emptyList() + + private val images = mutableMapOf() + + @Volatile private var loaded = false + + /** + * Parses the bundled fixture once. Safe to call more than once; only the first does anything. + */ + @Synchronized + fun ensureLoaded(context: Context) { + if (loaded) return + val lines = + context.assets.open("transcript.jsonl").bufferedReader().readLines().filter { + it.isNotBlank() + } + val parsed = lines.map { it to parseSeqEvent(it) } + log.addAll(parsed.take(BACKLOG_COUNT)) + streamTail = parsed.drop(BACKLOG_COUNT) + for (name in listOf("bench1.png", "bench2.png")) { + images[name] = context.assets.open(name).readBytes() + } + loaded = true + } + + /** The events the streaming phase has left to send. */ + fun remainingStreamEvents(): Int = streamTail.size + + /** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */ + fun pushNextLiveEvent(): Boolean { + val next = streamTail.firstOrNull() ?: return false + streamTail = streamTail.drop(1) + log.add(next) + return true + } + + /** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */ + @Synchronized + fun resetToBacklog(context: Context) { + loaded = false + log.clear() + ensureLoaded(context) + } + + fun fileBytes(name: String): ByteArray? = images[name] + + /** + * Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays. + */ + fun linesAfter(after: Long): List = + log.filter { it.second.seq > after }.map { it.first } + + /** + * One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores + * `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and + * what the benchmark exercises is the fold and the paging, not the server's row-joining, which + * client-core's own port tracks separately (CLIENT_CORE.md). + */ + fun page(before: Long?, limit: Int, after: Long?): List { + val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L) + val candidates = log.filter { + it.second.seq < upper && (after == null || it.second.seq > after) + } + return candidates.takeLast(limit).map { it.first } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt new file mode 100644 index 0000000..d9ff4c9 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt @@ -0,0 +1,181 @@ +package com.example.aiapp + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLStreamHandler +import java.net.URLStreamHandlerFactory +import java.security.Principal +import java.security.cert.Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException +import org.json.JSONArray + +/** + * Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more + * than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once + * per process, and a second real call throws -- so this guards it rather than relying on every + * caller to remember. + * + * Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's + * ordinary handler, so this only ever changes behaviour for the one host the bench build invents. + */ +@Synchronized +fun installFixtureNetworkOnce() { + if (installed) return + installed = true + URL.setURLStreamHandlerFactory( + URLStreamHandlerFactory { protocol -> + if (protocol != "https") null + else + object : URLStreamHandler() { + override fun openConnection(url: URL): HttpURLConnection = + if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url) + else + // The bench build makes no other https call -- this factory is + // installed only in FIXTURE_MODE (MainActivity) -- so there is + // deliberately no delegate to a platform handler here: once a + // URLStreamHandlerFactory is installed there is no supported way to + // ask the JDK for its own default handler back, and re-entering this + // same factory for the fallback would recurse forever rather than + // reach one. + throw java.io.IOException( + "bench build's fixture network has no route to https host " + + "${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served" + ) + } + } + ) +} + +private var installed = false + +/** + * Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of + * [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call + * `connect`/`disconnect`, set a handful of request properties they never need answered, and read + * `responseCode` and `inputStream`. + */ +private class FixtureConnection(url: URL) : HttpsURLConnection(url) { + private var input: InputStream? = null + private var writer: Thread? = null + + override fun connect() { + if (input != null) return + input = route(url.path, url.query) + } + + override fun disconnect() { + writer?.interrupt() + try { + input?.close() + } catch (_: IOException) {} + } + + override fun usingProxy() = false + + override fun getResponseCode(): Int { + connect() + return 200 + } + + override fun getInputStream(): InputStream { + connect() + return input!! + } + + override fun getErrorStream(): InputStream? = null + + // Nothing here reads any of these; implemented only because HttpsURLConnection declares them + // abstract. A fixture never negotiates real TLS, so each says exactly that rather than + // fabricating a plausible-looking certificate. + override fun getCipherSuite() = "none (bench fixture, no TLS)" + + override fun getLocalCertificates(): Array? = null + + override fun getServerCertificates(): Array = + throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") + + override fun getPeerPrincipal(): Principal = + throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") + + override fun getLocalPrincipal(): Principal? = null + + /** + * [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug. + */ + private fun route(path: String, query: String?): InputStream { + val params = + (query ?: "") + .split("&") + .filter { it.contains('=') } + .associate { + val (k, v) = it.split("=", limit = 2) + k to java.net.URLDecoder.decode(v, "UTF-8") + } + return when { + path.endsWith("/transcript") -> { + val lines = + BenchFixture.page( + before = params["before"]?.toLongOrNull(), + limit = params["limit"]?.toIntOrNull() ?: 80, + after = params["after"]?.toLongOrNull(), + ) + val body = JSONArray(lines.map { org.json.JSONObject(it) }) + ByteArrayInputStream(body.toString().toByteArray()) + } + path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L) + path.contains("/files/") -> { + val name = path.substringAfterLast("/files/") + val bytes = + BenchFixture.fileBytes(name) + ?: throw IOException("bench fixture has no file named $name") + ByteArrayInputStream(bytes) + } + else -> throw IOException("bench fixture has no route for $path") + } + } + + /** + * A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for + * anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's + * backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business + * rather than something worth a condition variable for. + */ + private fun openEventsStream(after: Long): InputStream { + val pipeIn = PipedInputStream(1 shl 16) + val pipeOut = PipedOutputStream(pipeIn) + var sent = after + val thread = Thread { + try { + while (!Thread.currentThread().isInterrupted) { + val fresh = BenchFixture.linesAfter(sent) + for (line in fresh) { + pipeOut.write("data: $line\n\n".toByteArray()) + pipeOut.flush() + sent = org.json.JSONObject(line).getLong("seq") + } + Thread.sleep(50) + } + } catch (_: InterruptedException) { + // disconnect() -- the ordinary way this ends. + } catch (_: IOException) { + // The reader side (Sse) closed its end. + } finally { + try { + pipeOut.close() + } catch (_: IOException) {} + } + } + .also { + it.isDaemon = true + it.start() + } + writer = thread + return pipeIn + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt new file mode 100644 index 0000000..48ca39b --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt @@ -0,0 +1,143 @@ +package com.example.aiapp + +import android.content.Context +import android.os.BatteryManager +import android.os.Process +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable + * system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop + * and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced + * here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build + * (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason + * [BenchFixture]'s doc comment gives. + */ +object BenchRun { + /** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */ + private const val CYCLES = 6 + private const val SWIPE_PX = 900f + private const val SWIPE_MS = 200 + private const val SWIPE_PAUSE_MS = 500L + + /** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */ + private const val STREAM_EVENTS_PER_SEC = 20 + private const val STREAM_SECONDS = 20 + + /** + * Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS, + * battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as + * `copyRenderReport` resets them, so the two accountings cover the same stretch of work. + */ + suspend fun run( + context: Context, + scope: CoroutineScope, + listState: LazyListState, + ): List { + FrameStats.reset() + DebugStats.reset() + val cpuStartMs = Process.getElapsedCpuTime() + + val battery = BatterySampler(context) + // Launched in the caller's scope rather than a fresh coroutineScope{} here, which would + // suspend this function until the sampler job ended -- and it only ends when told to. + val samplerJob = scope.launch { + while (isActive) { + battery.sample() + delay(1000) + } + } + + // The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer + // content and two back, so a cycle returns to where it started and the whole loop measures + // steady-state scrolling rather than travelling somewhere new each time. + repeat(CYCLES) { + repeat(2) { + listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS)) + delay(SWIPE_PAUSE_MS) + } + repeat(2) { + listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS)) + delay(SWIPE_PAUSE_MS) + } + } + + // Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to + // latest" tap is -- a reply streamed into a list parked further back arrives off-screen and + // the report would show nothing happened. + listState.scrollToItem(0) + + var sent = 0 + val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS + while (sent < total && BenchFixture.remainingStreamEvents() > 0) { + BenchFixture.pushNextLiveEvent() + sent++ + delay(1000L / STREAM_EVENTS_PER_SEC) + } + // Lets the last few deltas land and draw before the report is read. + delay(300) + + samplerJob.cancel() + val cpuMs = Process.getElapsedCpuTime() - cpuStartMs + val rssLine = peakRssLine() + val batteryLine = battery.finish() + + return listOf( + " scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events", + " process CPU time over this run: ${cpuMs}ms", + rssLine, + batteryLine, + ) + } + + /** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */ + private fun peakRssLine(): String { + val kb = + try { + File("/proc/self/status") + .readLines() + .firstOrNull { it.startsWith("VmHWM:") } + ?.trim() + ?.removePrefix("VmHWM:") + ?.trim() + ?.removeSuffix("kB") + ?.trim() + ?.toLongOrNull() + } catch (_: Exception) { + null + } + return " peak RSS: " + + (kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)") + } +} + +/** + * Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of + * a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most + * emulators -- and that is reported as "unavailable" rather than folded into an average with the + * real samples, which would silently understate every number after it. See UI_RULES: never present + * an inferred value as a measured one. + */ +private class BatterySampler(context: Context) { + private val manager = context.getSystemService(BatteryManager::class.java) + private val samples = mutableListOf() + + fun sample() { + val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) + if (value != null && value != Int.MIN_VALUE) samples.add(value) + } + + fun finish(): String { + if (samples.isEmpty()) return " battery current: unavailable on this device" + val meanUa = samples.sum() / samples.size + return " battery current: mean ${meanUa}µA over ${samples.size} samples" + + " (min ${samples.min()}, max ${samples.max()})" + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt index d1d7b48..695cf2c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt @@ -127,6 +127,13 @@ fun debugReport( frames: List, accounting: List, crash: String?, + /** + * P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on + * every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section + * heading only appears when there is something to put under it, so an ordinary copy from the + * render-report button reads exactly as it did before this existed. + */ + extra: List = emptyList(), ): String = buildString { appendLine("ai-app render report") appendLine(device) @@ -152,6 +159,11 @@ fun debugReport( appendLine("work since this was last copied:") val work = DebugStats.lines() if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } + if (extra.isNotEmpty()) { + appendLine() + appendLine("bench:") + extra.forEach { appendLine(it) } + } } /** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index f16b3c5..7bfb8ae 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() { // Transparent status bar on every version; the Surface below paints through underneath it // and content insets itself. Same reasoning as dev-updater's MainActivity. enableEdgeToEdge() + + // The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session + // screen against BenchFixture's in-process fake backend, with no enrollment, no network + // permission, and no notification prompt -- none of them mean anything with no server and + // no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built + // to talk to a real backend is made to talk to this instead. Still needs the same + // status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the + // platform's own default from Android 15 on this app's targetSdk, with or without the call + // above, so skipping the padding here put the header's own buttons under the status bar -- + // there to look at, but not there for `ui-trace`'s tap-by-label to land on. + if (BuildConfig.FIXTURE_MODE) { + installFixtureNetworkOnce() + BenchFixture.ensureLoaded(this) + setContent { + MaterialTheme(colorScheme = AiAppColors) { + Surface(modifier = Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { + SessionScreen( + settings = BenchFixture.settings, + summary = benchSessionSummary(), + onBack = { finish() }, + onFiles = {}, + ) + } + } + } + } + return + } // Dark status-bar icons only over a light background, decided from the scheme rather than // fixed. It was hardcoded to `true`, which was right against the default light surface and // became unreadable the moment the app wore Catppuccin Mocha. @@ -147,6 +176,26 @@ class MainActivity : ComponentActivity() { } } + /** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */ + private fun benchSessionSummary() = + SessionSummary( + id = BenchFixture.SESSION_ID, + setup = "bench", + setupName = "bench", + provider = "bench", + title = "P0 benchmark", + model = null, + keepsOwnTranscript = false, + permissionMode = null, + imported = false, + notify = false, + cwd = null, + contextTokens = null, + maxImageEdge = null, + status = "idle", + lastActivity = 0.0, + ) + // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open, // lands here rather than in a second activity instance. override fun onNewIntent(intent: Intent) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 12f2aff..1d674cf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1191,7 +1191,12 @@ fun SessionScreen( // the bench scripts keep working when this moves again. They pressed it at a hand-measured // coordinate until 2026-09-03, and anything that moved the header made that tap land on // whatever now sat there -- reporting a number that was never measured. - val copyRenderReport = { + // Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs + // between them is only whether there is a [extra] section, built by BenchRun.run beforehand -- + // everything about assembling, copying and logging the report is exactly the same act either + // way, and a second copy of it beside `onRunBenchmark` below would be the two silently + // disagreeing about what "the report" contains the first time either one changed. + fun buildAndCopyReport(extra: List = emptyList()) { val report = debugReport( device = @@ -1213,6 +1218,7 @@ fun SessionScreen( accounting = FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) }, crash = lastCrash(context), + extra = extra, ) context.copyToClipboard("ai-app render report", report) // Also to the log, so a session driving the app over adb can read the same report the @@ -1226,6 +1232,20 @@ fun SessionScreen( DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() } + val copyRenderReport = { buildAndCopyReport() } + // Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the + // fixture session opened below instead of a real server. Null everywhere else -- see + // [SessionSettingsDialog]'s onRunBenchmark. + val runBenchmark: (() -> Unit)? = + if (BuildConfig.FIXTURE_MODE) { + { + settingsOpen = false + scope.launch { + val extra = BenchRun.run(context, scope, listState) + buildAndCopyReport(extra) + } + } + } else null Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row( @@ -1869,6 +1889,7 @@ fun SessionScreen( }, onDismiss = { settingsOpen = false }, onCopyRenderReport = copyRenderReport, + onRunBenchmark = runBenchmark, ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index 4e116b2..ad44f41 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -66,6 +66,13 @@ fun SessionSettingsDialog( * measures is that screen's own state. */ onCopyRenderReport: () -> Unit, + /** + * Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on + * every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than + * always-present-but-disabled: this has no meaning at all outside the bench build, and a + * control with nothing behind it on every other build is not a state worth drawing. + */ + onRunBenchmark: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() var name by remember(sessionId) { mutableStateOf(title) } @@ -317,6 +324,21 @@ fun SessionSettingsDialog( Text("Render timings", modifier = Modifier.weight(1f)) TextButton(onClick = onCopyRenderReport) { Text("Copy") } } + // Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because + // ui-trace and the emulator smoke run find it by that label, the same way every + // other control here is found -- see AGENTS.md's "Driving the UI". + onRunBenchmark?.let { run -> + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.width(8.dp)) + Text("P0 benchmark", modifier = Modifier.weight(1f)) + TextButton(onClick = run) { Text("Run benchmark") } + } + } } }, // Disabled rather than absent while there is nothing to save: a button that comes and goes