The file explorer on the phone
The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- its event stream keeps flowing, its draft and scroll position stay where they were, and coming back from a file costs nothing. Back steps one level inside it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. The viewer is a `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text and a twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a9c547293
commit
db55ed4a8f
22 files changed
+1647
-161
No files matched your search
@@ -236,6 +236,8 @@ fun SessionScreen(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
onBack: () -> Unit,
|
||||
/** Opens the file explorer on this session's machine, starting where this session works. */
|
||||
onFiles: (FilesTarget) -> Unit,
|
||||
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
/** Said once [share] has been attached here, so it is not attached again. */
|
||||
@@ -1225,6 +1227,60 @@ fun SessionScreen(
|
||||
// interpolated, so there is nothing for a dropped frame to interrupt), so it is what both
|
||||
// places below fall back to.
|
||||
val imeVisible = WindowInsets.isImeVisible
|
||||
|
||||
// What this session is costing to draw, copied out to somewhere it can be read.
|
||||
//
|
||||
// Written here rather than beside the control that runs it, because everything it measures --
|
||||
// the events, the rows, the units, what the list has on screen, which cards are open -- is this
|
||||
// composable's own state, and a control in a dialog cannot reach it. The control is a row in
|
||||
// [SessionSettingsDialog]: that is where the session's other about-the-session controls are,
|
||||
// and the header is for what a reader presses while reading. It copies rather than opens,
|
||||
// because what it produces is for somewhere else -- a message to whoever is looking at the
|
||||
// code -- and a screenful of timings read on the phone is a screenful nobody can act on.
|
||||
//
|
||||
// Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves
|
||||
// "Session settings" and then "Copy render timings" from what is on screen at that moment, so
|
||||
// `transcript-bench.sh` and `stream-bench.sh` keep working when this moves again. They pressed
|
||||
// it at a coordinate measured once by hand 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 = {
|
||||
val report =
|
||||
debugReport(
|
||||
device =
|
||||
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
|
||||
" Android ${Build.VERSION.RELEASE}\n" +
|
||||
// A debuggable build runs Compose at a fraction of release speed, so a
|
||||
// report that did not say which it came from was read as the app's own
|
||||
// cost.
|
||||
"build: ${if (debuggable(context)) "debug" else "release"}",
|
||||
transcript =
|
||||
listOf(
|
||||
" ${items.size} events, ${rows.size} rows, ${units.size} units loaded",
|
||||
" viewport ${listState.layoutInfo.viewportSize.height}px," +
|
||||
" ${listState.layoutInfo.visibleItemsInfo.size} units visible",
|
||||
visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START),
|
||||
" ${expandedTools.size} tool calls and ${expandedGroups.size} groups open",
|
||||
),
|
||||
frames = FrameStats.lines(context.refreshHz()),
|
||||
accounting =
|
||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||
crash = lastCrash(context),
|
||||
)
|
||||
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
|
||||
// button copies. The clipboard is not reachable from a shell, and a counter nobody can
|
||||
// check from here is a counter that only gets checked by asking Iris to press a button
|
||||
// and paste.
|
||||
Log.i("ai-app", report)
|
||||
// Only once it is somewhere it can be read from, so a copy that never happened does not
|
||||
// throw the stack away with it.
|
||||
clearCrash(context)
|
||||
// Emptied by the copy, so pressing it twice measures two separate stretches of scrolling
|
||||
// rather than one and then the same one again.
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -1266,74 +1322,36 @@ fun SessionScreen(
|
||||
// yellow or red near a limit -- and the theme's plain control colour whenever there
|
||||
// is no measurement, since blue is the low end of the scale here and would read as
|
||||
// "checked, and fine" about a machine nobody could reach.
|
||||
// Usage, files, settings -- widest scope first, narrowing to the right, so the
|
||||
// cog stays at the end where every other screen keeps it. Asked for in this order
|
||||
// by Iris on 2026-09-03.
|
||||
Row {
|
||||
// Left of the numbers about the *conversation*, because it is the same kind of
|
||||
// thing about the *app*: what this session is costing to draw. It copies rather
|
||||
// than opens, because what it produces is for somewhere else -- a message to
|
||||
// whoever is looking at the code -- and a screenful of timings read on the
|
||||
// phone
|
||||
// is a screenful nobody can act on.
|
||||
GlyphButton(
|
||||
SPEED_GLYPH,
|
||||
"Copy render timings",
|
||||
onClick = {
|
||||
val report =
|
||||
debugReport(
|
||||
device =
|
||||
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
|
||||
" Android ${Build.VERSION.RELEASE}\n" +
|
||||
// A debuggable build runs Compose at a fraction of
|
||||
// release speed, so a report that did not say which
|
||||
// it came from was read as the app's own cost.
|
||||
"build: ${if (debuggable(context)) "debug" else "release"}",
|
||||
transcript =
|
||||
listOf(
|
||||
" ${items.size} events, ${rows.size} rows," +
|
||||
" ${units.size} units loaded",
|
||||
" viewport" +
|
||||
" ${listState.layoutInfo.viewportSize.height}px," +
|
||||
" ${listState.layoutInfo.visibleItemsInfo.size}" +
|
||||
" units visible",
|
||||
visibleUnits(
|
||||
units,
|
||||
listState.layoutInfo.visibleItemsInfo,
|
||||
UNITS_START,
|
||||
),
|
||||
" ${expandedTools.size} tool calls and" +
|
||||
" ${expandedGroups.size} groups open",
|
||||
),
|
||||
frames = FrameStats.lines(context.refreshHz()),
|
||||
accounting =
|
||||
FrameStats.drawPhase().let { (nanos, count) ->
|
||||
drawAccounting(nanos, count)
|
||||
},
|
||||
crash = lastCrash(context),
|
||||
)
|
||||
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 button copies. The clipboard is not reachable from a
|
||||
// shell, and a counter nobody can check from here is a counter that
|
||||
// only
|
||||
// gets checked by asking Iris to press a button and paste.
|
||||
Log.i("ai-app", report)
|
||||
// Only once it is somewhere it can be read from, so a copy that never
|
||||
// happened does not throw the stack away with it.
|
||||
clearCrash(context)
|
||||
// Emptied by the copy, so pressing it twice measures two separate
|
||||
// stretches
|
||||
// of scrolling rather than one and then the same one again.
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
},
|
||||
)
|
||||
GlyphButton(
|
||||
USAGE_GLYPH,
|
||||
"Usage",
|
||||
{ usageOpen = true },
|
||||
colour = usageGlyphColour(usage),
|
||||
)
|
||||
// The machine's files, which is where the answer to "what did it actually
|
||||
// change" is. It opens *over* this screen rather than replacing it -- see
|
||||
// [Screen.Session].
|
||||
GlyphButton(
|
||||
FOLDER_GLYPH,
|
||||
"Files",
|
||||
onClick = {
|
||||
onFiles(
|
||||
FilesTarget(
|
||||
setup = summary.setup,
|
||||
setupName = summary.setupName,
|
||||
// Where this session works, and the machine's own home when it
|
||||
// was never given a directory -- resolved there rather than
|
||||
// guessed at here, since this app does not know that machine's
|
||||
// home and must not invent one.
|
||||
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
// What it opens is about this session, so it sits at the end of the session's
|
||||
// own row. The name is the whole of what it holds today, which is why it is a
|
||||
// cog
|
||||
@@ -1948,6 +1966,7 @@ fun SessionScreen(
|
||||
settingsOpen = false
|
||||
},
|
||||
onDismiss = { settingsOpen = false },
|
||||
onCopyRenderReport = copyRenderReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user