diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt index 2e47cc7..d652645 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt @@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration * * Its own palette rather than the syntax one: a program that prints in red has chosen red, where a * highlighter's colours are this app's reading of somebody else's code. They come out of the same - * Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from - * somewhere else, but the two are not one table and must not become one -- adding a syntax role to - * this list would silently move `ls`'s directory blue. + * Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one + * table -- adding a syntax role to this list would silently move `ls`'s directory blue. */ data class AnsiPalette( /** Indexes 0-7, then 8-15 bright, in the terminal's own order. */ @@ -30,19 +29,18 @@ data class AnsiPalette( * What a tool printed, with its terminal styling applied and everything else taken out. * * Bash output arrives exactly as the program wrote it, escape sequences included, and drawn - * verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of - * every green word. Stripping them all would be the other half-answer -- colour is often the whole - * of what a diff, a test run or a linter is saying. + * verbatim those are line noise in the middle of the thing being read. Stripping them all would be + * the other half-answer -- colour is often the whole of what a diff or a test run is saying. * - * So the sequences that decide how text *looks* become spans, and every other one is dropped. - * Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript - * is a scrolling document, and "go to column 40" has no meaning here that is better than nothing. + * So the sequences that decide how text *looks* become spans, and every other one is dropped rather + * than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no + * meaning in a scrolling document. * * A carriage return is honoured the way a terminal honours it: what was written since the last line * break is thrown away and the line starts again. That is what makes a progress bar show its final - * state rather than every state it passed through, which was tens of lines run together. + * state rather than every state it passed through. * - * Not a composable, and the palette is a parameter: this can then be remembered against the text it + * Not a composable, and the palette is a parameter, so this can be remembered against the text it * parsed rather than re-run on every recomposition of the card holding it. */ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { @@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { if (final == 'm') sgr = sgr.apply(params, palette) } } - // A bare carriage return rewrites the line. One before a newline is the other half - // of a Windows line ending: it rewrites nothing, and it is dropped rather than kept, - // since that pair is one line break and the return itself would draw as a stray - // control character. + // A bare carriage return rewrites the line. One before a newline is the other half of a + // Windows line ending: it rewrites nothing, and it is dropped rather than kept, since + // that pair is one line break. c == '\r' && text.getOrNull(at + 1) != '\n' -> { flush() dropLine(runs) @@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { } c == '\r' -> at++ // Everything printable, plus the two control characters that are layout rather than - // terminal commands. A stray bell or backspace goes for the same reason a cursor - // move does. + // terminal commands. A stray bell or backspace goes for the same reason a cursor move + // does. c >= ' ' || c == '\n' || c == '\t' -> { plain.append(c) at++ @@ -129,9 +126,8 @@ private const val BELL = '\u0007' * Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte. * * One reader for every kind, because the point is to *leave* them all behind: a sequence this did - * not recognise would otherwise have its body printed as ordinary text, which is worse than the - * escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes - * (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones. + * not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI + * (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones. */ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int { val next = text.getOrNull(at + 1) ?: return at + 1 @@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un var end = at + 2 while (end < text.length && text[end] !in CSI_FINAL) end++ if (end >= text.length) { - // Cut off mid-sequence, which is what a stream that has not finished arriving - // looks like: drop the fragment rather than printing it, and the whole sequence - // arrives with the next delta. + // Cut off mid-sequence, which is what a stream that has not finished arriving looks + // like: drop the fragment rather than printing it, and the whole sequence arrives + // with the next delta. text.length } else { onCsi(text.substring(at + 2, end), text[end]) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index a5024b0..f1b1466 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -6,26 +6,23 @@ import java.net.URL import org.json.JSONArray import org.json.JSONObject -// The REST half of the backend's surface (see server/src/routes.rs for the -// table); the SSE half is EventStream.kt. All blocking network calls -- -// invoke from a background dispatcher. Each throws ApiException on failure, -// carrying the server's own explanation where it sent one, since those -// messages are written to be read on this screen. +// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is +// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws +// ApiException carrying the server's own explanation where it sent one, since those messages are +// written to be read on this screen. -// Shared with EventStream.kt, which connects the same way but then reads -// without a deadline. +// Shared with EventStream.kt, which connects the same way but then reads without a deadline. const val CONNECT_TIMEOUT_MS = 5000 private const val READ_TIMEOUT_MS = 5000 /** * A request that did not produce what it asked for, carrying the server's own wording where it sent - * some -- those messages are written to be read on the screen that made the call. + * some. * * [status] is the HTTP status where there was a response at all, and null where the server was * never reached. Callers that need it need it because the *same* failure is two different things to - * do: a 409 from a write is "somebody else changed this, here are three ways out", where every - * other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the - * message is what says that. + * do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should + * branch on it to decide what to *say* -- the message is what says that. */ class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) : Exception(message, cause) @@ -33,10 +30,10 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? = /** * Runs one request against the backend, with the pinned TLS setup, the bearer token, and the * failure translation every call needs. [readBody] gets the connected, already-status-checked - * connection to read from. + * connection. * * @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through - * here -- an event stream has no bounded read time (see EventStream.kt). + * here -- an event stream has no bounded read time. */ fun requestFromServer( settings: ServerSettings, @@ -44,9 +41,9 @@ fun requestFromServer( method: String = "GET", jsonBody: String? = null, /** - * A request body written as it is produced -- the upload path. Content type, and a writer - * handed the connection's stream. Sent chunked, since what a writer will produce is not known - * up front and the point is that a file never sits whole in memory on this side. + * A request body written as it is produced -- the upload path. Sent chunked, since what a + * writer will produce is not known up front and the point is that a file never sits whole in + * memory. */ streamBody: Pair Unit>? = null, readTimeoutMs: Int = READ_TIMEOUT_MS, @@ -87,9 +84,8 @@ fun requestFromServer( } catch (e: ApiException) { throw e } catch (e: IOException) { - // Surfacing the real exception (rather than one canned message for - // every failure mode) is what lets this be diagnosed on a device - // with no logcat access. + // Surfacing the real exception rather than one canned message for every failure mode is + // what lets this be diagnosed on a device with no logcat access. throw ApiException( "Couldn't reach the server at ${settings.baseUrl} " + "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " + @@ -107,11 +103,9 @@ fun requestFromServer( } } -/** The response body as one JSON object. */ private fun HttpURLConnection.jsonObject(): JSONObject = JSONObject(inputStream.bufferedReader().readText()) -/** The response body as a JSON array of objects, each mapped through [parse]. */ private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) @@ -120,21 +114,16 @@ private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } -/** Percent-encodes a value going into a query string. */ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) -// One row of GET /sessions. A session names the machine it runs on and -// which of that machine's providers it runs. +// One row of GET /sessions. A session names the machine it runs on and which of that machine's +// providers it runs. data class SessionSummary( val id: String, /** * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick - * this session's row out of the per-machine usage snapshots for the header's five-hour bar. - * - * It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a - * setup and holding both the id and the name invited showing the wrong one, which had already - * happened once. Something addresses one now, so the reason lapsed rather than being overruled. - * The guard that replaces it is the rule below: never show this. + * this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a + * reader sees, and holding both invites showing the wrong one. */ val setup: String, /** The machine's current label. This is the one to display; [setup] is never shown. */ @@ -147,9 +136,8 @@ data class SessionSummary( * provider's kind rather than here from its name. * * What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its - * own record of the conversation somewhere this app's delete does not reach. It is not a - * promise that the file is still there, and re-importing is not a restore -- this app's - * transcript holds things that record does not. + * own record somewhere this app's delete does not reach. It is not a promise that the file is + * still there, and re-importing is not a restore. */ val keepsOwnTranscript: Boolean, /** How much the session asks before acting; null when it was never set. */ @@ -162,35 +150,32 @@ data class SessionSummary( * Whether this session announces itself when it wants attention. * * Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws - * itself from a default is one you can turn off while believing you are reading it. Defaults to - * on when a backend is too old to say, which matches what that backend actually does. + * itself from a default is one you can turn off while believing you are reading it. */ val notify: Boolean, /** * The directory the session works in, or null where it was never given one. * - * Null is not "the home directory": it is the session never having been told, and what the - * process then starts in belongs to whatever launches it. Shown as unset rather than filled in - * with a guess, so a reader changing it is choosing rather than confirming. + * Null is not "the home directory": it is the session never having been told. Shown as unset + * rather than filled in with a guess, so a reader changing it is choosing rather than + * confirming. */ val cwd: String?, /** - * How much context this session is holding, as the server last measured it -- see - * `SessionEvent.UsageDelta`. + * How much context this session is holding, as the server last measured it. * * Null where nothing has been measured: a session that has not run a turn, a provider that does * not report usage, or a clear nobody has run a turn since. That is not zero, and the status * row says so in words rather than drawing an empty context for a conversation that may be - * nearly full. + * full. */ val contextTokens: Long?, /** * The longest edge an image should have when it reaches this session, or null where the * provider has no limit. * - * Null and "a big number" are different answers, and only the first stays true: a provider that - * does not care about size should not be given a threshold this app invented. Decided by the - * server because that is where a provider's kind is known -- see `uploadPickedImage`. + * Null and "a big number" are different answers, and only the first stays true. Decided by the + * server because that is where a provider's kind is known. */ val maxImageEdge: Int?, val status: String, @@ -225,18 +210,16 @@ fun fetchSessions(settings: ServerSettings): List = * * For screens whose controls are *set to* something rather than merely showing it. A screen opened * from a list row carries the row the list last fetched, which is a snapshot: fine for a title, - * wrong for a switch, since a switch drawn from a stale row shows a position that may have been - * changed since -- here or on another device -- and nothing on screen says which. + * wrong for a switch, since a stale row shows a position that may have been changed since. */ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } -// What the server offers, so the spawn screen has no hardcoded lists: a -// setup added to the server's config.ron appears here with no app rebuild. +// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's +// config.ron appears here with no app rebuild. // -// One list rather than two. A provider only exists on a machine that has -// it installed, so offering machines and providers as independent choices -// would offer pairs that cannot work. +// One list rather than two. A provider only exists on a machine that has it installed, so offering +// machines and providers as independent choices would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) /** @@ -275,7 +258,7 @@ fun fetchSetups(settings: ServerSettings): List = * A Claude Code session already on a machine, which can be continued here. * * Identified by [id] and never by a path. The server resolves which file that is, so this app has - * no way to ask it to read one -- the same rule that keeps a provider's command out of this client. + * no way to ask it to read one. */ data class Importable( val id: String, @@ -284,19 +267,15 @@ data class Importable( val modified: Double, val lines: Int, /** - * Size of the session file in bytes. - * - * Worth a place on the row because it is the only thing there that predicts what continuing the - * session costs, and the line count does not: these transcripts embed screenshots as base64, so - * a single line can be a megabyte. + * Size of the session file in bytes. Worth a place on the row because it is the only thing + * there that predicts what continuing the session costs, and the line count does not: these + * transcripts embed screenshots as base64, so a single line can be a megabyte. */ val bytes: Long, /** - * Tokens the model was holding at the last turn, or null if no turn has recorded any. - * - * The number that predicts what continuing this session costs. It disagrees with [bytes] in the - * direction that matters: most of a large transcript is usually history from before a - * compaction, which the model is no longer given. + * Tokens the model was holding at the last turn, or null if no turn has recorded any. It + * disagrees with [bytes] in the direction that matters: most of a large transcript is usually + * history from before a compaction, which the model is no longer given. */ val contextTokens: Long?, /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ @@ -305,17 +284,15 @@ data class Importable( * Whether a Claude Code is running this session right now. * * "unknown" is a third answer and not a synonym for "no": the machine may keep no record of - * what is running, and a session that cannot be checked is not a session that is free. The - * server refuses an import of a "yes"; the row says so before you press it. + * what is running. The server refuses an import of a "yes"; the row says so before you press + * it. */ val inUse: String, /** - * What this server is doing to the session right now -- "importing" or "deleting" -- or null - * when nothing is. + * What this server is doing to the session right now -- "importing" or "deleting" -- or null. * * The server's answer rather than the phone's, because the work outlives the screen that asked - * for it: leaving the import list and coming back has to show what is still running, and a - * phone that was asleep or out of range never saw the events that said so. + * for it: a phone that was asleep never saw the events that said so. */ val pending: String?, /** @@ -328,7 +305,7 @@ data class Importable( /** * One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing. * - * [operation] is only set by a start, and [message] only by a failure -- the three states are every + * [operation] is only set by a start and [message] only by a failure -- the three states are every * way an operation can be, and each carries exactly what that state knows. */ data class ImportableChange( @@ -348,20 +325,18 @@ fun parseImportableChange(payload: String): ImportableChange? = message = frame.optString("message").takeIf { it.isNotEmpty() }, ) } catch (_: org.json.JSONException) { - // A frame this build does not understand is not a reason to drop the stream: the listing - // is the truth and will say what happened whatever this missed. + // A frame this build does not understand is not a reason to drop the stream: the listing is + // the truth and will say what happened whatever this missed. null } /** * What a machine has that could be continued. * - * The slowest call this app makes, and it was the only expensive one left on the 5 second default — - * which is how it came to time out against a server that was answering perfectly well. Listing - * means reading every transcript Claude Code has ever written: about four seconds against a - * gigabyte of them before the tunnel adds anything, and that figure grows with every session - * anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how - * long the work takes rather than just above it. + * The slowest call this app makes, and it was the only expensive one left on the 5 second default + * -- which is how it came to time out against a server answering perfectly well. Listing means + * reading every transcript Claude Code has ever written: about four seconds against a gigabyte of + * them before the tunnel adds anything. A timeout is for a server that has stopped answering. */ fun fetchImportable(settings: ServerSettings, setup: String): List = requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { @@ -373,13 +348,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List = modified = session.optDouble("modified", 0.0), lines = session.optInt("lines", 0), bytes = session.optLong("bytes", 0L), - // Absent means nothing has been measured -- which is not a context of zero, so - // it stays null and the row simply does not claim a figure. + // Absent means nothing has been measured, which is not a context of zero. contextTokens = if (session.isNull("contextTokens")) null else session.optLong("contextTokens").takeIf { it > 0L }, - // Absent means an older backend that cannot answer, which is exactly what - // "unknown" says -- so the default is the honest one rather than "no". + // Absent means an older backend that cannot answer, which is what "unknown" says. inUse = session.optString("inUse", "unknown"), named = session.optBoolean("named", false), pending = session.optString("pending").takeIf { it.isNotEmpty() }, @@ -528,8 +501,7 @@ fun sendMessage( * * Throws rather than returning an outcome, because both ways of failing are things the reader has * to be told: 409 means the session was already given it, and 404 means nothing is waiting under - * that id. The bubble disappearing is the success case and it arrives on the event stream, not from - * here -- every device drops it, not only the one that tapped. + * that id. The bubble disappearing arrives on the event stream, so every device drops it. */ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) { requestFromServer( @@ -543,13 +515,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin /** * Moves a session to a different working directory. * - * The server checks the directory is there on that machine and refuses if it is not -- a mistyped - * path accepted here would surface much later, as a session that would not start, with nothing - * pointing at the typo. + * The server checks the directory is there and refuses if it is not -- a mistyped path accepted + * here would surface much later, as a session that would not start. * * Its process is **stopped**, because a working directory is settled when the process is spawned. - * The next thing said to the session starts it again in the new one, which is this app's rule for a - * session with no process everywhere else. + * The next thing said to the session starts it again in the new one. */ fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { requestFromServer( @@ -593,8 +563,8 @@ fun uploadAttachment( write(out) out.write(tail) }, - // Long: a trace is hundreds of megabytes, and the server copies it on to a remote - // machine before answering. + // Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine + // before answering. readTimeoutMs = 600000, ) { connection -> connection.jsonObject().getString("id") @@ -605,8 +575,7 @@ fun uploadAttachment( * One entry of a directory on the machine a setup names. * * [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link] - * still says it is one. Neither is worked out here -- the machine answers both, because it is the - * only thing that can. + * still says it is one. Neither is worked out here -- the machine answers both. */ data class DirEntry( val name: String, @@ -625,10 +594,10 @@ data class Listing(val path: String, val entries: List) /** * What reading a file produced. * - * Four cases, because they are four different things to draw and none of them is an error the - * screen can shrug off: content, something that is not text, something too big to have sent, and - * (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is - * [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these. + * Four cases, because they are four different things to draw and none is an error the screen can + * shrug off: content, something that is not text, something too big to have sent, and (as + * [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with + * an empty string, which is what it is. */ sealed class FileContent { abstract val path: String @@ -688,9 +657,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten requestFromServer( settings, "/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}", - // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before - // any of it moves. Well clear of that rather than just above it -- a timeout is for a - // server that has stopped answering. + // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any + // of it moves. Well clear of that rather than just above it. readTimeoutMs = 60000, ) { connection -> val body = connection.jsonObject() @@ -709,8 +677,7 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten "binary" -> FileContent.Binary(at, size, modified) "tooBig" -> FileContent.TooBig(at, size, modified) // A backend that has learned a fifth answer. Reported rather than guessed at: picking - // the nearest of the four would draw something confident about a state this app has - // never seen. + // the nearest of the four would draw something confident about a state never seen. else -> throw ApiException( "The server described this file as \"$kind\", which this app does not know how to show." @@ -721,9 +688,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten /** * Replaces a file's contents, but only while it still hashes to [ifSha256]. * - * The refusal is a 409 and arrives as an [ApiException] carrying the server's wording, which is - * what the conflict dialog shows -- an agent editing the same file while somebody reads it is the - * ordinary case here, not the exotic one. + * The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an + * agent editing the same file while somebody reads it is the ordinary case here. */ fun writeFile( settings: ServerSettings, @@ -770,7 +736,6 @@ fun createDir(settings: ServerSettings, setup: String, path: String) { ) {} } -/** Fetches an image the transcript references (produced or uploaded). */ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { it.inputStream.readBytes() @@ -779,10 +744,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): // One rate-limit window, rendered as a labeled bar on the usage screen. data class UsageWindow( /** - * The API's own word for which window this is -- "session" for the five-hour one. - * - * How to find a particular window. The label beside it is written for a person to read, so - * matching on it would select nothing the day its wording changes. + * The API's own word for which window this is -- "session" for the five-hour one. The label + * beside it is written for a person to read, so matching on it would select nothing the day its + * wording changes. */ val kind: String, val label: String, @@ -801,8 +765,8 @@ data class UsageSnapshot( * What came back: "ok", "notLoggedIn", "unreachable" or "failed". * * Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a - * machine somebody chose not to put an account on -- a fact, not a fault -- while the other two - * are faults worth chasing. Collapsing them made a healthy setup read as broken. + * machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made + * a healthy setup read as broken. */ val state: String, /** Why, for the two states that are faults. Absent otherwise. */ @@ -818,8 +782,8 @@ fun fetchUsage(settings: ServerSettings): List = provider = snapshot.getString("provider"), setup = snapshot.optString("setup"), setupName = snapshot.optString("setupName"), - // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" - // would draw an empty card as a healthy one. + // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would + // draw an empty card as a healthy one. state = snapshot.optString("state").ifEmpty { "failed" }, detail = snapshot.optString("detail").ifEmpty { null }, windows = @@ -840,8 +804,7 @@ fun fetchUsage(settings: ServerSettings): List = * Answers one question with everything that was chosen. * * A list even when one thing was picked, because that is the shape of the answer rather than a - * special case of it. What a provider makes of several answers is its own business and is decided - * on the server; nothing here joins, splits or reformats them for one. + * special case of it. What a provider makes of several answers is decided on the server. */ fun answerQuestion( settings: ServerSettings, @@ -868,9 +831,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) { /** * Ends the process behind a session, leaving the session and its transcript. * - * Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession] - * picks it back up. The server reports what it could not do -- there was nothing running, or the - * machine would not say whether there was -- rather than answering the same way either way. + * Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it + * back up. The server reports what it could not do rather than answering the same way either way. */ fun stopSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {} @@ -888,12 +850,10 @@ fun startSession(settings: ServerSettings, sessionId: String) { * caller confirms first; see ImportScreen. * * The work runs on the server, so this returning is not the same as it being done -- what says that - * is each row's own state, through [fetchImportable] and the change stream. That is the point: - * leaving the screen used to cancel the delete it had started. + * is each row's own state. That is the point: leaving the screen used to cancel the delete. * - * One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per - * row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were - * missed looked exactly like two that had not been picked. + * One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant + * a batch could half-arrive, and the rows that were missed looked exactly like rows not picked. */ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List) { requestFromServer( @@ -910,8 +870,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List> { val query = buildString { @@ -970,9 +927,8 @@ fun fetchTranscript( } return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> val body = JSONArray(connection.inputStream.bufferedReader().readText()) - // The text as well as the event: the transcript cache stores the one and the fold needs - // the other, and they have to be the same line -- a second entry point differing only in - // return type would be two answers to one question. + // The text as well as the event: the transcript cache stores the one and the fold needs the + // other, and they have to be the same line. (0 until body.length()).map { val line = body.getJSONObject(it).toString() line to parseSeqEvent(line) @@ -986,7 +942,7 @@ fun fetchTranscript( * The name is the backend's own -- it is what the list shows and it exists before any process does * -- so this settles it rather than asking. Where the thing running the session has a name of its * own, the backend passes it on, which is what makes a session the same session in Claude Code's - * picker and to any other agent that lists it. + * picker. */ fun renameSession(settings: ServerSettings, sessionId: String, title: String) { requestFromServer( @@ -1078,10 +1034,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} } -// Models: what this backend has downloaded, what it is downloading, and -// what HuggingFace offers. Browsing is proxied by the server rather than -// done here, because this app trusts exactly one certificate and has no -// general internet trust to spend on huggingface.co. +// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers. +// Browsing is proxied by the server rather than done here, because this app trusts exactly one +// certificate and has no general internet trust to spend on huggingface.co. data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index ae3a9c4..c1b000a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -29,11 +29,9 @@ import kotlinx.coroutines.withContext * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * and the back button the only other way between them. * - * Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views - * of the same backend, none of them a step down from another -- and what is left in this `when` is - * only what genuinely is a step down: one session, spawning one, and settings. A session's own - * settings are not among them: they are a dialog over the session, which is where the thing they - * change is. + * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of + * them a step down from another -- and what is left here is only what genuinely is a step down: one + * session, spawning one, and settings. */ private sealed class Screen { data object Main : Screen() @@ -43,10 +41,8 @@ private sealed class Screen { * * The explorer is a layer on this screen rather than a screen of its own, so the session under * it stays composed: its event stream keeps flowing, its scroll position and draft stay put, - * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and - * re-created on every return, refetching the transcript over the tunnel -- which is exactly the - * flip between "what did it change" and "what is it saying" that this feature exists for. The - * image viewer already made the same choice for the same reason. + * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re- + * created on every return, refetching the transcript over the tunnel. */ data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() @@ -60,7 +56,7 @@ private sealed class Screen { * * The notification names an id and nothing else, so opening it means fetching the session first. * [serial] tells two taps on the same session's notification apart, since they are two requests and - * would otherwise compare equal -- see MainActivity, which counts them. + * would otherwise compare equal. */ data class SessionOpenRequest(val sessionId: String, val serial: Int) @@ -68,8 +64,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int) private data class FailedOpen(val request: SessionOpenRequest, val message: String) /** - * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), - * re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. + * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re- + * reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. * * [openRequest] is the session a notification tap asked for, likewise from MainActivity. * @@ -89,8 +85,8 @@ fun AppRoot( // A notification tap this could not follow, and why. Null both before one is asked for and // after one succeeds, since success is a screen rather than a message. var failedOpen by remember { mutableStateOf(null) } - // Bumped whenever another screen changes something the list shows, so - // returning to it refetches instead of showing a stale list. + // Bumped whenever another screen changes something the list shows, so returning to it + // refetches. var reloadToken by remember { mutableIntStateOf(0) } // Cleared by the session screen that attached it, not when a newer request arrives: a share // must be attached exactly once, and only the screen that did it knows that it has. @@ -104,9 +100,8 @@ fun AppRoot( } } - // A standing condition rather than a per-request failure, so it is - // stated once here instead of appended to every error that might be - // caused by it. Without this the app is simply unreachable and every + // A standing condition rather than a per-request failure, so it is stated once here instead of + // appended to every error it might cause. Without this the app is simply unreachable and every // screen blames the server or the tunnel for it. if (!localNetworkAllowed(context)) { Text( @@ -121,8 +116,8 @@ fun AppRoot( val current = settings if (current == null) { - // Not enrolled yet: settings is the only usable screen. The QR - // path lands in MainActivity and recomposes from the top. + // Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity + // and recomposes from the top. Box(Modifier.imePadding()) { SettingsScreen( existing = null, @@ -136,10 +131,9 @@ fun AppRoot( return } - // The one way back, whichever screen is showing and whether it was - // reached by the system back gesture or a screen's own Back button. - // Every leaf screen can have changed something the list shows, so it - // always refetches. + // The one way back, whichever screen is showing and whether it was reached by the system back + // gesture or a screen's own Back button. Every leaf screen can have changed something the list + // shows, so it always refetches. val goToMain = { reloadToken++ screen = Screen.Main @@ -149,8 +143,8 @@ fun AppRoot( } // Turning a notification into the screen it points at. The id has to be resolved to a session - // first, because that is what SessionScreen is given -- and unlike a list row, which is a - // snapshot the list already fetched, there is nothing here to seed it from. + // first, because that is what SessionScreen is given -- and unlike a list row, there is nothing + // here to seed it from. // // A failure is reported rather than swallowed: somebody deliberately tapped a notification, so // an app that opens to the session list with no explanation looks like the tap missed. @@ -180,10 +174,9 @@ fun AppRoot( ) } - // Every screen but the session takes the keyboard as bottom padding here. The session - // screen deliberately does not: resizing a whole screen on every frame of the keyboard - // animation is the cost that made it lag, so it moves only its composer and transcript -- - // see the layout note in SessionScreen. + // Every screen but the session takes the keyboard as bottom padding here. The session screen + // deliberately does not: resizing a whole screen on every frame of the keyboard animation is + // the cost that made it lag, so it moves only its composer and transcript. when (val here = screen) { is Screen.Main -> Box(Modifier.imePadding()) { @@ -202,15 +195,14 @@ fun AppRoot( } is Screen.Session -> // Keyed on the id, because a different session is a different screen rather than this - // one showing other rows. SessionScreen remembers a transcript, an open event stream, a - // draft and a scroll position, and without the key Compose keeps all of it across the - // change and merges two conversations -- which crashes the list on the first duplicate - // row key. Only reachable since a notification can move straight from one session to - // another; every other way here passes through [Screen.Main], which disposes it anyway. + // one showing other rows. SessionScreen remembers a transcript, an open stream, a draft + // and a scroll position, and without the key Compose keeps all of it across the change + // and merges two conversations -- which crashes the list on the first duplicate row + // key. Only reachable since a notification can move straight from one session to + // another. key(here.summary.id) { - // A Box so the explorer can be drawn *over* the session rather than instead of - // it; the session stays composed underneath. No imePadding here, for the reason - // above -- the explorer adds its own, since it has a text field. + // A Box so the explorer can be drawn *over* the session rather than instead of it. + // No imePadding here, for the reason above -- the explorer adds its own. Box { SessionScreen( settings = current, @@ -257,8 +249,7 @@ fun AppRoot( // Last, so it draws over the screen above rather than under it: these are stacked in the Box // the activity puts around this, and that Box paints in the order it was given. A session - // wanting attention is not a fact about the page somebody happens to be on, so it is not the - // page's job to leave room for it. Tapping one is the same act as tapping a notification, so - // it goes through the same `open`, failure dialog included. + // wanting attention is not a fact about the page somebody happens to be on. Tapping one is the + // same act as tapping a notification, so it goes through the same `open`. SessionAlerts(onOpen = { request -> scope.launch { open(request) } }) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 3e09769..6bf3d11 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -41,13 +41,12 @@ data class QuestionAnswer(val questionId: String, val answers: List) * What the reader has settled on for one question, before any of it is sent. * * Held here rather than inferred from the transcript, which is what made picking an option feel - * broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and - * come back as an event, so on a phone the card sat unchanged for most of a second after a tap and - * the natural response was to tap again. + * broken: the mark used to appear only when the answer had crossed the tunnel and come back as an + * event, so the card sat unchanged for most of a second after a tap. * * Picked options and typed words are one field each because they are alternatives rather than - * parts: answering in the reader's own words is the case no option covers, so typing puts the picks - * away and picking puts the words away, and there is never a draft that means two things. + * parts: typing puts the picks away and picking puts the words away, so there is never a draft that + * means two things. */ data class Draft(val picked: Set = emptySet(), val other: String = "") { val settled: Boolean @@ -65,29 +64,26 @@ data class Draft(val picked: Set = emptySet(), val other: String = "") { /** * Every question one tool call is waiting on, one at a time. * - * All of it comes from the question events themselves -- what each option means, what picking it - * would produce, whether several may be picked at once. None of it is read out of the call's own + * All of it comes from the question events themselves. None of it is read out of the call's own * input, which is one provider's JSON: parsing that here would put that provider's schema in the * app, where no other provider can reach it and where it drifts the first time the schema moves. * * One question on screen with arrows to the others, rather than all of them stacked. A card asking * three questions with four options and a description each is several screens tall, so the reader - * scrolls past the question they are answering to reach the button that sends it, and never sees - * the whole of any one of them. Paged, each question is a screen and the count says how many are - * left -- which is also what makes "not all of them are answered" something the reader can act on - * rather than something to go hunting for. + * scrolls past the question they are answering to reach the button that sends it. Paged, each + * question is a screen and the count says how many are left. * * Nothing is sent until Submit. Answering is one act even when it is several questions: the tool - * asked them together and is waiting on all of them, and sending each as it was tapped meant the - * reader could not change their mind about the first after reading the third. + * asked them together, and sending each as it was tapped meant the reader could not change their + * mind about the first after reading the third. */ @Composable fun AskUserQuestionBody( asks: List, onAnswer: (List, onSettled: () -> Unit) -> Unit, ) { - // Seeded from what was already answered, so a card the reader comes back to shows their - // answers rather than an empty draft over them. + // Seeded from what was already answered, so a card the reader comes back to shows their answers + // rather than an empty draft over them. var drafts by remember(asks.map { it.id }) { mutableStateOf( @@ -125,7 +121,7 @@ fun AskUserQuestionBody( modifier = Modifier.weight(1f), ) // Disabled at the ends rather than absent, so the pair keeps its place and the - // reader can see that there is nothing further that way. + // reader can see there is nothing further that way. MarkButton("Previous question", { at-- }, enabled = at > 0) { Chevron(Pointing.Left, colour = LocalContentColor.current) } @@ -143,7 +139,7 @@ fun AskUserQuestionBody( if (outstanding.isNotEmpty()) { Spacer(Modifier.height(12.dp)) // Greyed until every question has an answer, because the tool is waiting on all of - // them: a submit that sent two of three would leave the third one asked and the card + // them: a submit that sent two of three would leave the third asked and the card // looking dealt with. val ready = outstanding.all { drafts[it.id]?.settled == true } Button( @@ -155,8 +151,8 @@ fun AskUserQuestionBody( } ) { // Back to a button whatever happened. A refusal is reported by the screen - // around this, and the draft is still here to send again -- a spinner - // that never stops would be the only sign of a failure this card cannot + // around this, and the draft is still here to send again -- a spinner that + // never stops would be the only sign of a failure this card cannot // describe. sending = false } @@ -165,8 +161,8 @@ fun AskUserQuestionBody( modifier = Modifier.fillMaxWidth(), ) { if (sending) { - // In the button rather than beside it, so the row does not change height at - // the moment it is pressed. + // In the button rather than beside it, so the row does not change height at the + // moment it is pressed. CircularProgressIndicator( Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp, @@ -186,8 +182,7 @@ fun AskUserQuestionBody( * One question: what is being asked, what can be answered, and what was. * * The same body wherever a question appears -- on the call that asked it, or as a card of its own - * when nothing did. A question is the same thing either way, and two renderings of it would be two - * places for an answer to go missing. + * when nothing did. Two renderings of it would be two places for an answer to go missing. * * [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here * sends anything. An answered question ignores both and draws what was answered. @@ -214,10 +209,10 @@ fun AskedQuestion( // replacing them with a line repeating it. The options are what the question *was*, and // dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says // very little without the three it was chosen over. Marked in the same purple that says - // "picked" while the question is still open, so it is one appearance learned once. + // "picked" while the question is open, so it is one appearance learned once. val answered = ask.answers.isNotEmpty() - // What is marked: what was answered once there is an answer, and what the finger has - // chosen until then. + // What is marked: what was answered once there is an answer, and what the finger has chosen + // until then. val marked = if (answered) ask.answers.toSet() else draft.picked // Null once the question is answered: the options stay and stop being pressable. val onPick: ((String) -> Unit)? = @@ -233,9 +228,9 @@ fun AskedQuestion( } } } - // What was answered in the reader's own words, which no option can mark -- see - // [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered - // by picking says it by the mark alone. + // What was answered in the reader's own words, which no option can mark. Only ever the + // answers that match nothing offered, so a question answered by picking says it by the + // mark. val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } } if (inWords.isNotEmpty()) { Text( @@ -252,10 +247,8 @@ fun AskedQuestion( } /** - * [label] added to, or taken out of, what [draft] has picked. - * - * A single-answer question replaces rather than accumulates, and either way picking puts any typed - * words away -- see [Draft]. + * [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces + * rather than accumulates, and either way picking puts any typed words away -- see [Draft]. */ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = when { @@ -269,8 +262,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = * * Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was * indistinguishable from the card behind it -- three paragraphs of text where three things to press - * should have been, which is the failure a tint step routinely produces on a dark theme. A border - * is one cue and it is unambiguous. + * should have been. A border is one cue and it is unambiguous. */ @Composable private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) { @@ -324,8 +316,8 @@ private fun Preview(preview: String) { preview, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines - // of the thing being previewed. + // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of + // the thing being previewed. softWrap = false, modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()), ) @@ -336,8 +328,7 @@ private fun Preview(preview: String) { * The choice the asker always leaves open, and the app has to as well. * * Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words - * rather than pick. Leaving it out narrows a question that was never that narrow, and the reader - * cannot tell that it was ever open. + * rather than pick. Leaving it out narrows a question that was never that narrow. */ @Composable private fun OtherAnswer(text: String, onText: (String) -> Unit) { @@ -358,8 +349,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) { * * A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question * with four options showed the first one or two and dropped the rest off the side of the screen. - * That does not read as a bug: it reads as those having been the only choices, which is the worst - * way for a list of choices to be wrong. + * That reads as those having been the only choices. */ @Composable fun AnswerOptions( @@ -379,8 +369,8 @@ fun AnswerOptions( OutlinedButton( onClick = { onPick?.invoke(option.label) }, // Disabled rather than removed, so an answered question still shows what it - // offered. Material dims a disabled button's own border and label, which would - // take the mark with it -- both are stated here instead. + // offered. Material dims a disabled button's own border and label, which would take + // the mark with it -- both are stated here instead. enabled = onPick != null, border = BorderStroke( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt index 4fbf975..b53d82e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt @@ -28,8 +28,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref) /** * One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A - * file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the - * name is the whole of it. + * file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all + * of it. */ @Composable fun Attachment( @@ -50,8 +50,7 @@ fun Attachment( /** * A file's name, one line, in the face names are read in. Overlong names lose their middle: a name - * is identified by both ends -- what it is at the front, what kind at the back -- and either - * ellipsis alone takes away one of them. + * is identified by both ends -- what it is at the front, what kind at the back. */ @Composable fun FileName(name: String, modifier: Modifier = Modifier) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt index f37f3f5..2a00cf2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt @@ -20,10 +20,8 @@ import kotlin.math.max * to be either thrown away or rejected -- which is what "sending an image is broken" was. * * Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the - * expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from - * the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's - * requirements are known, and a phone that carried its own copy of them would be a second place to - * update when one changes. + * expensive part on a phone is the upload, not the decode. What the limit *is* comes from the + * server, per session, because that is where a provider's requirements are known. */ suspend fun uploadPickedImage( context: Context, @@ -53,17 +51,16 @@ suspend fun uploadPicked( if (mime != null && mime.startsWith("image/")) { return uploadPickedImage(context, settings, sessionId, uri, maxEdge) } - // Opened before the request starts, so a provider that refuses says so here and not from - // inside the connection; then streamed, since a trace or a log is bigger than this process - // should hold at once. + // Opened before the request starts, so a provider that refuses says so here and not from inside + // the connection; then streamed, since a trace is bigger than this process should hold at once. val source = openSource(resolver, uri) val name = displayName(resolver, uri) return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out -> try { source.use { it.copyTo(out, COPY_BUFFER) } } catch (e: java.io.IOException) { - // Either side of the copy can fail; the message names the file, which is the - // part the reader can do something about. + // Either side of the copy can fail; the message names the file, which is the part the + // reader can do something about. throw ApiException("couldn't send $name: ${e.message}", cause = e) } } @@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024 * * A share arrives with whatever access the other app granted, and a provider that refuses says so * with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both - * are things the reader can act on, so neither is left to end the process. + * are things the reader can act on. */ private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream = try { @@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String { /** * The bytes to upload and what they are, scaled down only if they need to be. * - * An image already inside the limit is uploaded exactly as it came, rather than decoded and - * re-encoded to the same size: a round trip through JPEG loses a little every time, and there is - * nothing to gain from it. This is also the path a provider with no limit always takes. + * An image already inside the limit is uploaded exactly as it came, rather than decoded and re- + * encoded to the same size: a round trip through JPEG loses a little every time. This is also the + * path a provider with no limit always takes. */ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair { val resolver = context.contentResolver @@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair 0f } } catch (_: java.io.IOException) { - // No EXIF, or none this can read. Upright is the assumption every - // image without the tag is displayed under anyway. + // No EXIF, or none this can read. Upright is the assumption every image without the tag is + // displayed under anyway. 0f } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt index 2da613f..a2e72f9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt @@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp -// The composer's row of settings and pickers, and the menus they open. One file because the -// outline and the corner are one appearance: a control shaped like this opens a surface shaped -// like this, and a reader learns the pair once. +// The composer's row of settings and pickers, and the menus they open. One file because the outline +// and the corner are one appearance: a control shaped like this opens a surface shaped like this. /** * A bordered pill: a control that can be seen without being pressed. * * The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at - * all until they are touched. Three bare words sitting under the message field read as a caption - * about the field rather than as three things to press, and the only way to find out otherwise was - * to press one. The outline says "control" without the weight of a filled button, which is reserved - * here for the two that act on the session (send, and start/stop). + * all until they are touched. Three bare words under the message field read as a caption about the + * field rather than as three things to press. The outline says "control" without the weight of a + * filled button, which is reserved for the two that act on the session. */ @Composable fun BubbleButton( @@ -32,8 +30,8 @@ fun BubbleButton( onClick = onClick, enabled = enabled, shape = BubbleShape, - // A text button's padding rather than a filled button's 24dp: these sit three across - // under the message field, and the wider padding is what decides whether the row fits. + // A text button's padding rather than a filled button's 24dp: these sit three across under + // the message field, and the wider padding is what decides whether the row fits. contentPadding = ButtonDefaults.TextButtonContentPadding, modifier = modifier, ) { @@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50) * The corner on a menu one of these opens. * * A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding - * ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge - * beside a one-line option, which is the shortest menu here. + * ends that tall would bow its sides. */ val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt index 67786cb..6dfad69 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt @@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp * of the operation over it. * * One composable rather than a pattern each list repeats, because "this row is busy" has to look - * the same in the import list and the session list or the appearance becomes a per-screen dialect - * rather than something the reader learns once. + * the same in the import list and the session list or the appearance becomes a per-screen dialect. * * [label] names the operation and `null` means none is running. One parameter rather than a boolean - * beside a string, which can disagree: there is no such thing as busy with nothing happening. It is - * a *word* because a spinner alone cannot say which operation this is — deleting and importing are - * different in kind, and losing a session to the wrong one is not recoverable by waiting. + * beside a string, which can disagree. It is a *word* because a spinner alone cannot say which + * operation this is -- deleting and importing are different in kind. * * It does **not** make the row inert; the caller disables its own click handling while it passes a - * label. That was the other way round at first — an overlay consuming pointer events, so no caller - * had to remember — and it swallowed the drag along with the tap, which meant a list could not be - * scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a - * gesture is above the components that already decide it; disabling the click is the platform's own - * answer and leaves the scroll where it belongs. + * label. That was the other way round at first -- an overlay consuming pointer events -- and it + * swallowed the drag along with the tap, so a list could not be scrolled while anything in it was + * busy. */ @Composable fun BusyItem(label: String?, content: @Composable () -> Unit) { @@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) { /** * How an item looks while it is being acted on: darker, and nearly grey. * - * Both, rather than either alone. Dimming by itself is what this app already used for a row on its - * way out, and it is the same cue as a disabled control, so a busy row read as one more thing that - * could not be tapped. Draining the colour is what says the row is *suspended* — the status word, - * the accent on a warning and everything else that means something by its colour stop meaning it - * for as long as the operation runs, which is exactly true: none of them is being kept up to date. + * Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a + * busy row read as one more thing that could not be tapped. Draining the colour is what says the + * row is *suspended* -- the status word and everything else that means something by its colour stop + * meaning it for as long as the operation runs, which is exactly true. * - * Not all the way to grey. A row with no colour left is hard to find again in a list, and the - * reader is watching this one. + * Not all the way to grey: a row with no colour left is hard to find again in a list. */ private fun Modifier.busy(busy: Boolean): Modifier = if (!busy) this diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt index 36cda0c..088312a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -27,13 +27,10 @@ enum class Pointing { * * One composable for all four directions rather than one per axis that differ by which coordinate * gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one - * direction. The shape is written once in its own coordinates, where x runs across the opening and - * y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto - * the box. + * direction. The shape is written once in its own coordinates, and [Pointing] is only a table of + * how those map onto the box. * - * It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole - * of what assistive technology has to go on, and it is also the answer to "what was that arrow for" - * six months from now. + * It draws no label of its own, so every caller owes it a `contentDescription`. */ @Composable fun Chevron( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt index 5bcf9ad..072f4e7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode * sits on, scrolling sideways rather than wrapping. * * The renderer's own fence drew the same block in plain text. The scanner that colours a tool - * call's command colours a reply's code the same way, through [highlighted] and one palette, so a - * `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has - * no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and - * plain is what the reader would have seen before. + * call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool + * wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than + * wrongly coloured. * * Finding the code is still the library's: which children of the node are the fence markers, the - * language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence] - * hands out the code and the language and leaves the drawing to the block it is given. + * language word and the code between them is its knowledge of the parser. */ @Composable fun CodeFence( @@ -67,14 +65,12 @@ fun CodeBlock( /** * The code inside a fence or indented block, and the highlighter's language for its info word. * - * Which children of the node are the fence markers, the language word and the code between them is - * the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called: - * that one is a composable, and the whole point of this function is that [warm] can run it on a - * background thread and highlight the same string the drawing will ask for. Two extractions would - * be two keys, and the warmed answer would be silently missed at every fence. + * Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and + * the whole point here is that [warm] can run this on a background thread and highlight the same + * string the drawing will ask for. Two extractions would be two keys, and the warmed answer would + * be silently missed at every fence. * - * Null for a fence too short to hold anything -- an unterminated one still arriving, which the - * library skips as invalid. + * Null for a fence too short to hold anything -- an unterminated one still arriving. */ fun fenceContent(content: String, node: ASTNode): Pair? { val word = @@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair? { * * The renderer's own block, less what nothing here needs: the same background, corner, padding and * sideways scroll, without the shadow, the border and the empty pointer handler it also carried. - * The vertical margin is the renderer's too, kept so a reply's fences sit where they always have. */ @Composable private fun CodeBlockText( @@ -117,8 +112,7 @@ private fun CodeBlockText( .semantics { isTraversalGroup = true } ) { BasicText( - // No language while the block is still being written, which is what draws it plain; - // see [MarkdownRoot]'s `streaming`. + // No language while the block is still being written, which is what draws it plain. replies.highlighted(code, language.takeUnless { streaming }), style = style, modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), @@ -141,14 +135,12 @@ fun fenceLanguage(name: String?): Language? = * The highlighter's language for a *file*, from its name. * * The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people - * write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets - * written there as the language's name. One table rather than two, so a language added for fences - * is a language added for files and neither can be the one somebody forgot. + * write after the backticks. One table rather than two, so a language added for fences is a + * language added for files and neither can be the one somebody forgot. * - * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin and - * `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that - * starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name - * with no dot at all -- `Makefile`, `LICENSE` -- is likewise null, and null is drawn plain. + * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A + * leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name + * with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain. */ fun fileLanguage(name: String): Language? { val dot = name.lastIndexOf('.') @@ -206,10 +198,9 @@ private val FENCE_LANGUAGES: Map = ) /** - * Every fence in [parse], as the code and language [highlight] will be asked for. - * - * Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn - * the same way and costs the same to lex. + * Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole + * tree rather than the top level: a fence inside a list item or a quote is drawn the same way and + * costs the same to lex. */ fun fences(parse: State): List> { val success = parse as? State.Success ?: return emptyList() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt index fe2a7a6..f8da571 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp * These are the two this app understands, and understanding them is what lets it show them: a * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed - * through to whatever runs the session, because a dialect's own vocabulary is its own and grows - * without this list -- it just arrives unannounced and unexplained. + * through, because a dialect's own vocabulary grows without this list. */ data class SessionCommand( /** With the slash, as it is typed and as it is sent. */ @@ -90,8 +89,8 @@ fun CommandSuggestions( verticalAlignment = Alignment.CenterVertically, ) { Text( - // The command in the colour commands are, so the suggestion and the - // bubble it becomes are visibly the same thing. + // The command in the colour commands are, so the suggestion and the bubble + // it becomes are visibly the same thing. if (command.argument == null) command.name else "${command.name} <${command.argument}>", style = MaterialTheme.typography.titleSmall, @@ -117,8 +116,7 @@ fun CommandSuggestions( * anything appearing here. * * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a - * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes - * and reads as having been missed. + * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes. */ @Composable fun CommandBubble(text: String, waiting: Boolean = false) { @@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) { modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), ) { Column(Modifier.padding(12.dp)) { - // Stated beside the fill rather than inherited: a semantic colour has to carry - // its own contrast, because the surface under it will not change to rescue it. + // Stated beside the fill rather than inherited: a semantic colour has to carry its + // own contrast, because the surface under it will not change to rescue it. Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) if (waiting) { Spacer(Modifier.height(6.dp)) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index 7ed3e64..0b199b9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier * The mark a compaction leaves in the transcript. * * A divider rather than something anybody said: everything above it is out of the session's context - * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it - * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which - * a clear also uses, so the two marks cannot drift apart. + * now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider], + * which a clear also uses, so the two marks cannot drift apart. * - * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it, - * which is the same thing the status line says while the compaction runs. + * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it. */ @Composable fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { @@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi * What to say about a compaction: the two sizes, and nothing else. * * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer - * to why the wait was worth it -- and they are all this says, because a divider is read in passing. - * When they were not reported this says only that a compaction happened, rather than filling in a - * plausible number or explaining at length what was missing. + * to why the wait was worth it. When they were not reported this says only that a compaction + * happened, rather than filling in a plausible number. */ fun compactionSummary(item: TranscriptItem.CompactedNote): String { val pre = item.preTokens @@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String { * A token count as a reader reads one. * * Shared with the status row rather than formatted at each: the divider and the row report the same - * quantity about the same moment, and one of them grouping its thousands while the other did not - * read as two different measurements. + * quantity about the same moment, and one grouping its thousands while the other did not read as + * two different measurements. */ fun tokens(count: Long): String = "%,d".format(count) @@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count) * What the working indicator says while a compaction is running. * * Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a - * compaction has begun and then says nothing until it has finished, so any bar, percentage or - * estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been - * going forty seconds is what a reader actually wants -- it is the difference between waiting and - * going to look at why. + * compaction has begun and then says nothing until it has finished, so any bar or estimate here + * would be this screen's guess wearing a measurement's clothes. * * [seconds] is null when this device did not see the compaction start, which is what opening a - * session that is already compacting looks like. That case says only "compacting": no number is the - * honest answer, and a number counted from the moment the screen opened would be wrong in the - * direction that matters, since a compaction somebody is asking about is a long one. + * session that is already compacting looks like. That case says only "compacting": a number counted + * from the moment the screen opened would be wrong in the direction that matters. */ fun compactingLabel(seconds: Long?): String = when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt index a174267..8ef3037 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt @@ -12,10 +12,9 @@ import java.util.Locale * The last crash, kept so the debug button can hand it over. * * The alternative is asking somebody to reproduce a crash with the phone plugged into a computer - * and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a - * crash report that arrives a day later, without the stack, is a guess. This costs one file write - * on a process that is already dying, and it turns "it crashes when I open that chat" into the - * frame it crashed in. + * and `logcat` running, which is the one thing nobody has set up at the moment it happens. This + * costs one file write on a process that is already dying, and it turns "it crashes when I open + * that chat" into the frame it crashed in. * * Kept until it is read rather than cleared on the next launch: the app restarts before anybody can * ask about it, so a log that lives for one session is a log that is never read. @@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt" * How much of a stack is kept. * * This is pasted into a conversation, so it has a budget like any other output written for a - * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so - * what gets cut is the part nobody reads. + * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing. */ private const val CRASH_LIMIT = 4000 @@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000 * Records uncaught exceptions, then lets the platform do what it was going to do. * * Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and - * ends the process, and an app that swallows that instead sits there in an unknown state. This only - * adds a witness. + * ends the process, and an app that swallows that instead sits there in an unknown state. */ fun installCrashLog(context: Context) { val app = context.applicationContext 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 dcc85ea..d1d7b48 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt @@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong * * Here because the emulator cannot answer the question this is for. Its own scroll sits at the same * frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost - * is under the floor of what it can measure, and a frame number taken in it says nothing about a - * 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply - * parsed, is the same number on any machine, and it is the number that says whether the work is - * proportional to what is on screen or to everything ever loaded. + * is under the floor of what it can measure. Counts do not have that problem: how many times a row + * was composed, or a reply parsed, is the same number on any machine, and it is the number that + * says whether the work is proportional to what is on screen or to everything ever loaded. * * Always on rather than behind a build flag. What is measured is an atomic increment on paths that * already allocate lists and parse markdown, and a counter that is only compiled into the build @@ -90,14 +89,12 @@ object DebugStats { * * The draw phase is where Compose's measurement lands as well as its recording -- the platform * calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three - * different things is high. The transcript times its own measure, its own placement and its own - * recording, and this is the subtraction that was otherwise done by hand in a conversation every - * time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout, - * which grows with how many nodes are alive rather than with how many are on screen. + * different things is high. The transcript times its own measure, placement and recording, and this + * is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout, + * which grows with how many nodes are alive rather than how many are on screen. * * Per frame rather than in total, because the budget it has to fit in is per frame. The recordings - * are not themselves per-frame -- a measurement happens on the frames that need one -- so these are - * shares of an average frame, not a claim about any particular one. + * are not themselves per-frame, so these are shares of an average frame. */ fun drawAccounting(drawNanos: Long, frames: Int): List { if (frames == 0 || drawNanos == 0L) return emptyList() @@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List { * frames went, and what the app did to produce them. * * Written for somebody to paste into a conversation, so it is plain text with the units on every - * number -- a report whose reader has to ask what the columns mean costs another round trip, and - * the whole point of it is to save one. + * number -- a report whose reader has to ask what the columns mean costs another round trip. */ fun debugReport( device: String, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt index 92633c3..01a13ab 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -21,11 +21,7 @@ import androidx.compose.ui.unit.dp * reader scrolling back, both mean "the session no longer has what is above this", and which of the * two it was is said by the words and the colour. * - * The rules take [color] too, so the whole divider reads as one mark of one kind rather than a - * coloured phrase sitting in an unrelated grey line. - * - * Written once here rather than styled at each of them, so the two cannot drift into looking like - * different kinds of thing. + * The rules take [color] too, so the whole divider reads as one mark of one kind. */ @Composable fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) { @@ -44,9 +40,8 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) * The mark a clear leaves. * * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a - * compaction it summarises nothing and measures nothing, so there is nothing to report but the - * fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is - * why this does not say it. + * compaction it summarises nothing and measures nothing. Everything above stays on screen and stays + * scrollable -- the reader can see that, which is why this does not say it. */ @Composable fun ClearedRow(modifier: Modifier = Modifier) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt index 7ba2635..75f7c0c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt @@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts" * * On this device rather than on the backend, which is where this app otherwise keeps state so that * every device sees it. A draft is the case that rule is not about: it is the contents of a text - * box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing - * on another device would be a surprise rather than a convenience. What has been *sent* is the - * server's, and that is the part which has to outlive this phone. + * box on the phone somebody is holding, and half a sentence surfacing on another device would be a + * surprise. What has been *sent* is the server's. * - * Kept per session id, because the thing being typed belongs to the conversation it is aimed at: - * one shared box would hand a message meant for one session to whichever was opened next. + * Kept per session id: one shared box would hand a message meant for one session to whichever was + * opened next. */ fun loadDraft(context: Context, sessionId: String): String = context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty() @@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String = /** * Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep. * - * The path out is emptying the box, which is what sending does -- so a sent message removes its own - * entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a - * draft does leave its key behind: pruning those means a pass over the live session list, which - * this file would otherwise have no reason to know about, and the residue is a few bytes per - * session ever abandoned mid-sentence. That is a trade rather than an oversight. + * The path out is emptying the box, which is what sending does. A session *deleted* while it held a + * draft does leave its key behind: pruning those means a pass over the live session list, and the + * residue is a few bytes per session ever abandoned mid-sentence. */ fun saveDraft(context: Context, sessionId: String, text: String) { context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt index bc18ad8..30db84c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt @@ -5,13 +5,12 @@ package com.example.aiapp * * A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two * halves, because a short span and a long one are read for different things. Under a minute the - * question is "roughly how long", so only the largest unit is shown and a fraction of it carries - * the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit - * that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather - * than written as zero, since the labels say which is which and `5d 0h 4m` is only longer. + * question is "roughly how long", so only the largest unit is shown and a fraction carries the rest + * -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something + * in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero. * * Sub-second precision is dropped past a minute: nothing that takes days is measured in - * milliseconds, and carrying them would make the common case the widest one. + * milliseconds. */ fun formatMillis(ms: Long): String { if (ms < 0) return "-" + formatMillis(-ms) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index 4176411..748b3fc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -12,7 +12,7 @@ private const val RESET_EVENT = "reset" * * The connection and its framing belong to [Sse]; what stays here is what this stream's frames * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it - * saw as the new cursor. See SessionScreen. + * saw as the new cursor. */ class EventStream(settings: ServerSettings, private val sessionId: String) { private val stream = Sse(settings) @@ -24,20 +24,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) { * * [onReset] fires when the server answers that the cursor is too far behind to continue from: * everything already displayed is stale and the events that follow are a fresh window, so the - * caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It - * arrives before those events, so a caller that clears on it stays in order. + * caller drops what it holds and rebuilds. It arrives before those events, so a caller that + * clears on it stays in order. */ fun run( after: Long, onOpen: () -> Unit, onReset: () -> Unit, - // The frame's own text as well as the event parsed from it: the transcript cache stores - // the one and the screen folds the other, and they have to be the same line. + // The frame's own text as well as the event parsed from it: the transcript cache stores the + // one and the screen folds the other, and they have to be the same line. onEvent: (raw: String, event: SeqEvent) -> Unit, ) { stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> - // A named frame carries no payload and a data frame has no name, so this is one or - // the other. + // A named frame carries no payload and a data frame has no name. if (name == RESET_EVENT) onReset() else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 661409d..f266f70 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -2,10 +2,9 @@ package com.example.aiapp import org.json.JSONObject -// The common event model, mirrored from server/src/session/driver.rs -- -// the app renders purely from this stream (replayed from the transcript by -// cursor, then live), so there is no separate "load history" shape to keep -// in sync with it. +// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from +// this stream (replayed from the transcript by cursor, then live), so there is no separate "load +// history" shape to keep in sync with it. /** One transcript line: the event plus its resume cursor and time. */ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) @@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) /** * One choice offered in answer to a question. * - * More than a label because the reader is deciding rather than confirming: what an option means, - * and what picking it would produce, are the things that decide it. Both are absent on a + * More than a label because the reader is deciding rather than confirming. Both are absent on a * permission, whose Allow and Deny mean exactly what they say. */ data class QuestionOption(val label: String, val description: String?, val preview: String?) @@ -30,13 +28,12 @@ sealed class SessionEvent { */ val id: String?, /** - * What was attached to it, by the ref the files route serves: images, and since 2026-09-03 - * any file, told apart by [isImageRef]. + * What was attached to it, by the ref the files route serves: images, and any file, told + * apart by [isImageRef]. * * On the message rather than beside it: these arrived as separate image events until * 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent - * it, and left this app deciding from adjacency alone which message an image went with -- - * something the sender knew and could simply have said. + * it, and left this app deciding from adjacency which message an image went with. */ val attachments: List, ) : SessionEvent() @@ -45,11 +42,10 @@ sealed class SessionEvent { * A message the server has accepted and the session has not read yet. * * From the server, not from this app's memory of what it sent. The pending bubble used to be - * screen state, so leaving the session or restarting the app drew nothing waiting while the - * message was still queued -- and nothing waiting is what "there is nothing" looks like. + * screen state, so leaving the session drew nothing waiting while the message was still queued + * -- and nothing waiting is what "there is nothing" looks like. * - * Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by - * [CommandSent]. + * Resolved by the [UserMessage] carrying the same id. */ data class MessageQueued(val id: String, val text: String, val attachments: List) : SessionEvent() @@ -59,8 +55,7 @@ sealed class SessionEvent { * * Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects * replays both, and without this one it would put back a bubble for a message that is never - * coming -- with nothing left to resolve it, since the [UserMessage] that normally does is - * exactly what was cancelled. + * coming. */ data class MessageDropped(val id: String) : SessionEvent() @@ -108,17 +103,14 @@ sealed class SessionEvent { * * The live Claude Code path only learns a turn was somebody else's when the turn ends, so * the event arrives below everything it caused; this is what puts it back above it. Null - * for a message read out of a session file, which is already in the right place, and for - * one that started no turn. See the server's `Event::PeerMessage`. + * for a message read out of a session file, and for one that started no turn. */ val turnStart: Long? = null, ) : SessionEvent() /** - * A command the session was asked to run on itself and cannot run yet. - * - * Resolved by [CommandSent] with the same id. A command that ran straight away has only that - * one, so nothing here ever draws a bubble that resolves in the same frame. + * A command the session was asked to run on itself and cannot run yet. Resolved by + * [CommandSent] with the same id; a command that ran straight away has only that one. */ data class CommandQueued(val id: String, val text: String) : SessionEvent() @@ -130,29 +122,26 @@ sealed class SessionEvent { /** * What the session is set to, as the session itself reports it. * - * Either field alone: the two are confirmed separately and by different things. Asking for a - * change is not having one, so this -- not the request -- is what the pickers show. + * Either field alone: the two are confirmed separately. Asking for a change is not having one, + * so this -- not the request -- is what the pickers show. */ data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() /** * What a turn cost, and how much the model was holding when it ended. * - * [context] is prompt plus both cache figures, measured by the backend from the turn's own - * usage. Carried on the event rather than summed by the reader, because it is not a sum: a - * conversation's context drops at a compaction and a clear, so adding turns up would report a - * figure the session stopped being true of. Null where the dialect did not say, and on entries - * recorded before the backend sent it -- which leaves the context unmeasured rather than - * unchanged. + * [context] is prompt plus both cache figures. Carried on the event rather than summed by the + * reader, because it is not a sum: a conversation's context drops at a compaction and a clear, + * so adding turns up would report a figure the session stopped being true of. Null where the + * dialect did not say, which leaves the context unmeasured rather than unchanged. */ data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent() /** * A compaction that finished, and how much context it recovered. * - * The counts are nullable because the server sends them only when it was told them: a - * compaction whose size nobody measured has to be able to say so, since a zero here would read - * as "recovered nothing" and a made-up number would read as a measurement. + * The counts are nullable because the server sends them only when it was told them: a zero here + * would read as "recovered nothing" and a made-up number would read as a measurement. */ data class Compacted( val preTokens: Long?, @@ -163,9 +152,7 @@ sealed class SessionEvent { /** * The conversation was cleared. Everything above this is still here to read and is no longer in - * the session's context. - * - * An object rather than a class because it carries nothing: what it means is entirely its + * the session's context. An object rather than a class because what it means is entirely its * position in the transcript. */ data object Cleared : SessionEvent() @@ -173,17 +160,15 @@ sealed class SessionEvent { data class Error(val message: String) : SessionEvent() /** - * An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new - * event kind degrades to a placeholder row instead of killing the stream. + * An event type this app build doesn't know -- a newer server. Kept rather than thrown so one + * new event kind degrades to a placeholder row instead of killing the stream. */ data class Unknown(val type: String) : SessionEvent() } /** - * A JSON array of strings under [name], empty when the field is absent. - * - * Absent is the ordinary case -- most messages carry no attachment, and the server omits the field - * rather than sending an empty list -- so this is the shape every caller wants. + * A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since + * the server omits the field rather than sending an empty list. */ private fun JSONObject.stringList(name: String): List { val array = optJSONArray(name) ?: return emptyList() @@ -212,8 +197,8 @@ fun parseSeqEvent(json: String): SeqEvent { SessionEvent.ToolStart( id = body.getString("id"), tool = body.getString("tool"), - // Kept as raw JSON text: the input shape is the tool's own - // business, and the UI only ever shows it verbatim. + // Kept as raw JSON text: the input shape is the tool's own business, and the UI + // only ever shows it verbatim. input = body.get("input").toString(), ) "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) @@ -282,36 +267,33 @@ fun parseSeqEvent(json: String): SeqEvent { return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) } +/** + * Whether [state] is one the session is doing work in -- the states a turn is still open under. + * + * One predicate because two readers have to agree on the list: the session screen's working + * indicator, and the fold's decision that the newest reply is finished. Two copies would drift the + * first time the server grows a state, and the drift would be a reply that never splits or one + * split mid-stream. + */ +fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" + /** * The context after [event], given what it was before. * * The same rule the server folds with, because the screen has to keep up between page loads: the - * summary it opened with is a measurement from before this stream started, and every event that - * moves the figure arrives here. + * summary it opened with is a measurement from before this stream started. * * The two that lower it are the point. A clear takes the conversation away and a compaction * replaces it with a summary, so a figure measured before either stopped being true at that moment * -- and carrying it forward is how a session that had just been cleared went on reporting the * context it no longer had. * - * Null is "we don't know", which is a state each of them can reach: nothing measured yet, a - * compaction that finished without saying how much it recovered, or a clear nobody has run a turn - * since. + * Null is "we don't know", which each of them can reach. */ -/** - * Whether [state] is one the session is doing work in -- the states a turn is still open under. - * - * One predicate because two readers have to agree on the list: the session screen's working - * indicator, and the fold's decision that the newest reply is finished - * ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a - * state, and the drift would be a reply that never splits or one split mid-stream. - */ -fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" - fun contextAfter(current: Long?, event: SessionEvent): Long? = when (event) { - // Falls back to what we had, so a turn the dialect reported no usage for is stale by a - // turn -- which every context figure is -- rather than unknown. + // Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn + // -- which every context figure is -- rather than unknown. is SessionEvent.UsageDelta -> event.context ?: current is SessionEvent.Compacted -> event.postTokens is SessionEvent.Cleared -> null diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt index ddbc11f..a467296 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextAlign /** * The largest file this app will open in the editor, in bytes. * - * Measured on the emulator on 2026-09-04, in a debug build, on generated Rust: + * Measured on the emulator 2026-09-04, in a debug build, on generated Rust: * * | file | lines | scan per keystroke | worst frame record | typing | * |--------|--------|--------------------|--------------------|-------------------| @@ -35,15 +35,13 @@ import androidx.compose.ui.text.style.TextAlign * | 1 MB | 28,660 | -- | -- | stops responding | * * The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file - * costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one - * `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what - * EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out - * one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is - * the way past this and is a good deal more than this feature needed. + * costs 40ms a keystroke, which is survivable, while laying the same text out in one + * `BasicTextField` costs two seconds. So switching highlighting off above a size -- what + * EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single + * text field pays that cost. A line-by-line editor is the way past this. * - * 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was - * actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB: - * reading a big file is fine, and it is only editing one that is not. + * 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays + * the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not. */ const val EDIT_LIMIT = 32L * 1024 @@ -53,18 +51,15 @@ const val EDIT_LIMIT = 32L * 1024 * `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement * that colours a field's own text rather than replacing the field with something that only looks * like one: the transformation returns the text unchanged and the scanner's spans as styles, so - * [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The - * newer `TextFieldState` API has no hook for styles at all, which is why this is the older one. + * [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook + * for styles at all. * - * The cost is that the whole file is re-scanned on every keystroke. For a file under the server's - * limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is - * where a size below which highlighting is switched off would be decided if it turns out to be - * needed. + * The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is + * sized against. * * The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because - * there are no rows here -- the field is one text object. It stays put while the text scrolls - * sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line - * is a visual line. + * there are no rows here -- the field is one text object. It lines up for the same reason the + * viewer's does: nothing wraps, so a logical line is a visual line. */ @Composable fun FileEditor( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index f5fa96c..e9129d6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -12,10 +12,8 @@ import androidx.compose.ui.text.buildAnnotatedString * and again on every recomposition. * * Why per line at all: 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 one `Text` measures all of it to - * draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets - * into the whole file -- so the spans are bucketed here, once, in one pass over an already-ordered - * list, rather than each row searching the whole list for the part that is its. + * layout is linear in the text. That means each row needs *its* colours, and the scanner answers in + * offsets into the whole file -- so the spans are bucketed here, once, in one pass. */ class FileLines private constructor( @@ -37,11 +35,9 @@ private constructor( get() = lines.size /** - * One line, coloured. - * - * Built when the row is composed rather than up front: a file has far more lines than a screen - * shows, and an `AnnotatedString` per line for all of them is the cost the lazy list exists to - * avoid. + * One line, coloured. Built when the row is composed rather than up front: a file has far more + * lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the + * lazy list exists to avoid. */ fun line(index: Int): AnnotatedString { val text = lines[index] @@ -60,16 +56,13 @@ private constructor( * * Exactly one trailing newline is dropped before splitting, so a file that ends the way * text files are supposed to end has the number of lines its author would count -- `wc -l` - * agrees, and so does every editor. Without that, every well-formed file gained a phantom - * empty last line, which is a wrong line number on every file in the repository. An empty - * file is one empty line numbered 1, which is what it is: a file with nothing in it still - * has somewhere for a cursor to go. + * agrees. Without that, every well-formed file gained a phantom empty last line. An empty + * file is one empty line numbered 1, which is what it is. */ fun of(text: String, language: Language?): FileLines = - // Timed, and always, for the same reason everything else here is: the cost of opening - // a large file is the number that decides whether the server's size limit is right, - // and an instrument that is only in the build nobody is running answers nothing. It - // lands in the render report beside the transcript's own figures. + // Timed, and always, for the reason everything else here is: the cost of opening a + // large file is the number that decides whether the server's size limit is right, and + // an instrument that is only in the build nobody is running answers nothing. DebugStats.timed("file scanned and cut into lines") { val body = text.removeSuffix("\n") val lines = body.split('\n') @@ -80,10 +73,9 @@ private constructor( /** * How many columns a line occupies. * - * A tab counts as eight rather than as one, and deliberately upwards: this decides how far - * the viewer can scroll, and over-estimating leaves a little empty space past the longest - * line where under-estimating makes the end of that line unreachable. Compose draws a tab - * as a single advance, so eight is the generous reading rather than the accurate one. + * A tab counts as eight rather than one, and deliberately upwards: this decides how far the + * viewer can scroll, and over-estimating leaves a little empty space past the longest line + * where under-estimating makes the end of that line unreachable. */ private fun columnsOf(line: String): Int { var count = 0 @@ -95,10 +87,9 @@ private constructor( * The scanner's spans, in file offsets, as spans per line in line offsets. * * One walk down both lists, which is what the scanner's guarantee buys: its spans come out - * ordered, non-overlapping and inside the text, so a span can only belong to the line the - * walk has reached or to ones after it. A span crossing a line break -- a block comment, a - * multi-line string -- is cut at each break and appears in each line it covers, because a - * row is drawn on its own and cannot inherit a colour from the row above. + * ordered, non-overlapping and inside the text. A span crossing a line break is cut at each + * break and appears in each line it covers, because a row is drawn on its own and cannot + * inherit a colour from the row above. */ private fun bucket(lines: List, spans: List): List> { val out = ArrayList>(lines.size) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 58d2501..d60c5dc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -50,15 +50,12 @@ fun codeStyle(): TextStyle = /** * [content] scanned off the main thread, then drawn. * - * Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file - * (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was - * first written, that is 460ms of frozen screen at the size the server is willing to send -- long - * enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like - * from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees - * meanwhile, in the place the file will appear. + * Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660 + * lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first + * written, that is 460ms of frozen screen at the size the server is willing to send -- long enough + * that the accessibility tree cannot be read, which is what "the app has stopped" looks like. * - * Keyed on the text and the language, so re-reading the same file does not rescan it and a file - * that changed does. + * Keyed on the text and the language, so re-reading the same file does not rescan it. */ @Composable fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { @@ -76,39 +73,31 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * A file, one line per row, coloured by the same scanner that colours a reply's code fences. * * A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a - * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful, and the - * scroll never recovers. The cost of the choice is that each row needs its own colours, which is - * what [FileLines] works out once and off this thread. + * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is + * that each row needs its own colours, which is what [FileLines] works out once and off this + * thread. * * Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a * block and a long line does not silently become three -- which would put the gutter's numbers - * against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a - * logical line is one visual line and the two cannot drift. + * against the wrong text. * * **Every row is given the same content width**, and that is what makes the shared scroll state * behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset * into *its own* range -- `content width - viewport` -- so with rows of their natural widths a - * short line's range is zero and it never moves at all while a long one beside it does. Each row - * also writes `maxValue` on the shared state as it measures, so how far the file could be dragged - * was decided by whichever row happened to measure last and changed as the list scrolled. Both - * disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves - * as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to - * affect different rows differently", which is exactly what a per-row range looks like. + * short line's range is zero and it never moves while a long one beside it does. Each row also + * writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever + * row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on + * 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range + * looks like. * * The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box - * around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so - * only the line under the finger stretched and the rest of the file sat still beside it -- the same - * complaint as the offsets above, one layer further out. Handing every row the same effect and - * rendering it once is what makes the file bend as the block it scrolls as. Only possible because - * every row now has the same range: rows that disagreed about where the end was would disagree - * about when to stretch. + * around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise, + * so only the line under the finger stretched. Only possible because every row now has the same + * range. * * The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the - * numbers out of both effects: they do not travel with the text and they do not bend with it. The - * rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is - * measured from the digit count of the line count in the very style it is drawn in, so a nine-line - * file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by - * hand. + * numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width + * is measured from the digit count of the line count in the style it is drawn in. * * Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and * copying it gives the code rather than the code with a number in front of every line. @@ -139,8 +128,8 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { softWrap = false, // The scroll outside the width: the scrolling node's viewport is // what the row has room for, and its content is the whole file's - // widest line. The shared effect is given to every row and - // rendered by none of them -- see the box above. + // widest line. The shared effect is given to every row and rendered + // by none of them -- see the box above. modifier = Modifier.horizontalScroll(scroll, overscroll).width(content), ) @@ -157,24 +146,20 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { * The line numbers, drawn beside the file rather than in it. * * They have to be outside the box the stretch is rendered on, or they bend with the text; and they - * have to stay exactly level with the lines they number, which is the one thing a numbered listing - * may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it. + * have to stay exactly level with the lines they number. Those two pull in opposite directions. * * A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come * from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during - * measurement, so this is not composing from a value it read a frame ago, it is composing from the - * answer the list has just produced. A `Column` translated by the scroll position could not do - * that: the translation would be a layout read and current while the set of numbers would be a - * composition behind it, so during a fling the numbers would slide against their lines. + * measurement, so this composes from the answer the list has just produced rather than one it read + * a frame ago. A `Column` translated by the scroll position could not: the translation would be + * current while the set of numbers was a composition behind, so during a fling the numbers would + * slide against their lines. * - * The list is measured before this is -- they are siblings in a `Box` and it is declared first -- - * and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass, - * which is the same reason a lazy list does not lag its own content. + * The list is measured before this is -- they are siblings in a `Box` and it is declared first. * - * `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and - * the text's own colour would put it in the same voice as the code. The background is painted - * because the stretch can carry the text sideways under this column, and a digit with a smear of - * code behind it reads as a rendering fault. + * `onSurfaceVariant`, because a number is not part of the file. The background is painted because + * the stretch can carry the text sideways under this column, and a digit with a smear of code + * behind it reads as a rendering fault. */ @Composable private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { @@ -205,10 +190,9 @@ private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { /** * How wide the widest line number is, measured rather than guessed. * - * `9` repeated, because digits in a monospace face are all one width and the count's own digits - * would measure the same -- what matters is how many there are. Measuring in the style the numbers - * are drawn in is what makes this survive a font size, a density or a display scale nobody here - * chose. + * `9` repeated, because digits in a monospace face are all one width -- what matters is how many + * there are. Measuring in the style the numbers are drawn in is what makes this survive a font + * size, a density or a display scale nobody here chose. */ @Composable fun gutterWidth(lineCount: Int, style: TextStyle): Dp { @@ -225,15 +209,15 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp { /** * How wide to make every row: the widest line in the file, in this style. * - * One character measured rather than the line itself, because the face is monospace -- every - * advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is - * work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not - * carry a whole character's worth of rounding. + * One character measured rather than the line itself, because the face is monospace and measuring + * the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already + * has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of + * rounding. * * Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary - * one: a minified file is a single line of a hundred thousand characters, and asking to lay that - * out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line - * cannot be reached, which is the tolerable half of that trade. + * one: a minified file is a single line of a hundred thousand characters, and laying that out as + * one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be + * reached, which is the tolerable half of that trade. */ @Composable private fun contentWidth(columns: Int, style: TextStyle): Dp { @@ -252,9 +236,7 @@ private fun contentWidth(columns: Int, style: TextStyle): Dp { private const val MAX_CONTENT_PX = 100_000f /** - * The space between the numbers and the code. - * - * A gap, not an alignment: the two are already aligned by the row, and this is only so the digits - * and the first character of the line are not touching. + * The space between the numbers and the code. A gap, not an alignment: the two are already aligned + * by the row, and this is only so the digits and the first character are not touching. */ val GUTTER_GAP = 8.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index e2ebac4..c4bfe25 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext * Which machine's files to show, and where to start. * * A **setup**, not a session: a filesystem is a property of a machine, and a session only says - * where it was working. That is what makes a second way in -- from the setups tab, say -- one more + * where it was working. That is what makes a second way in -- from the setups tab -- one more * caller rather than any new code here. */ data class FilesTarget(val setup: String, val setupName: String, val start: String) @@ -61,13 +61,13 @@ private sealed class Spot(val path: String) { * The files on the machine a session runs on: browse them, read one, change one. * * Drawn **over** the session rather than instead of it (see [AppRoot]), so 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 here -- editor to viewer, viewer to the directory it came - * from, directory to the one above it -- and only closes from where it opened. + * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to + * viewer, viewer to the directory it came from, directory to the one above -- and only closes from + * where it opened. * - * Every directory that has been visited is kept for as long as this is open, so stepping back is - * instant; the refresh glyph is how a directory gets asked again on purpose, and creating something - * refetches the directory it was created in, since that is the one thing that changed. + * Every directory that has been visited is kept for as long as this is open; the refresh glyph is + * how one gets asked again on purpose, and creating something refetches the directory it was + * created in. */ @Composable fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { @@ -121,17 +121,16 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un Box( Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background) - // The session under this deliberately takes no keyboard inset (see SessionScreen's - // layout note), so the explorer adds its own -- otherwise the editor types under the - // keyboard. + // The session under this deliberately takes no keyboard inset, so the explorer adds its + // own -- otherwise the editor types under the keyboard. .imePadding() ) { Column(Modifier.fillMaxSize()) { when (val spot = here) { is Spot.Dir -> { val state = listings[spot.path] ?: LoadState.Loading - // The resolved path once there is one: a directory opened as `~` is called - // what it turned out to be, not what it was asked for. + // The resolved path once there is one: a directory opened as `~` is called what + // it turned out to be, not what it was asked for. val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path FilesHeader( title = baseName(at), @@ -304,9 +303,8 @@ private fun ColumnScope.DirectoryBody(state: LoadState, onOpen: (Spot) * * A symlink says so instead of giving a size, because the size a listing reports for one is the * length of the path it points at -- a number that looks exactly like a file size and is about - * something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the - * row still appears, because a directory that hid what it held would be lying about being empty, - * and the word is there because a colour cannot say "this is a different kind of thing". + * something else. `other` covers a fifo, a device, and a link whose target is gone: the row still + * appears, because a directory that hid what it held would be lying about being empty. */ private fun trailingOf(entry: DirEntry): String? = when { @@ -350,8 +348,7 @@ private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () * * Its own composable so that everything about one file -- what came back, what has been typed, and * whether a save is out -- is remembered under that file's path and thrown away when the reader - * moves to another. What is *not* here is edit mode itself: back has to know about it, and back - * belongs to the screen. + * moves to another. What is *not* here is edit mode itself: back has to know about it. */ @Composable private fun ColumnScope.DocPane( @@ -423,8 +420,8 @@ private fun ColumnScope.DocPane( onDirty(false) onEditing(false) } catch (e: ApiException) { - // The one refusal that is a question rather than a message: somebody else's edit - // is on the machine, and which of the two survives is not this app's to decide. + // The one refusal that is a question rather than a message: somebody else's edit is + // on the machine, and which of the two survives is not this app's to decide. if (e.status == 409) conflict = e.message ?: "It changed on the machine." else saveError = e.message } finally { @@ -467,10 +464,10 @@ private fun ColumnScope.DocPane( ) } - // Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say - // why it is disabled -- and a reader who cannot edit a file they can plainly read will - // otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a - // disabled button never receives. + // Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it + // is disabled -- and a reader who cannot edit a file they can plainly read will otherwise + // conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button + // never gets. if (loaded != null && !editable) { Text( "Too big to edit here (${humanSize(loaded.size)}; the limit is " + @@ -687,6 +684,5 @@ internal fun baseName(path: String): String { return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/') } -/** A resolved directory and a name in it, as one path. */ internal fun join(directory: String, name: String): String = if (directory.endsWith("/")) "$directory$name" else "$directory/$name" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt index 61ffae5..2bd210c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt @@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext * The point of splitting it up is that "the scroll is laggy" has two completely different causes * and one appearance. If the layout-and-measure and draw figures are small and the total is large, * the time is going into rasterising and compositing, and no amount of doing less work per row will - * move it. If they are large, the work per row is the problem and it is ours to fix. Guessing - * between those two is how a day gets spent rewriting the half that was already fast. + * move it. If they are large, the work per row is the problem and it is ours to fix. * * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, - * broken down into the parts the UI thread is responsible for -- handling input, running - * animations, measuring and laying out, recording the draw -- and the parts after it. + * broken into the parts the UI thread is responsible for and the parts after it. * * One of these for the app, like [DebugStats], because the two are read as one report and * [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session * and the counters were not, so a report copied after visiting two sessions divided every session's - * work by the newest one's frame count -- and printed the result as a per-frame measurement. It - * said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at - * 0.00ms (0%), which reads as a screen whose whole cost is this app's own code. + * work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window. */ object FrameStats { private val total = ArrayList() @@ -54,7 +50,7 @@ object FrameStats { total += metrics.getMetric(FrameMetrics.TOTAL_DURATION) // How long the frame waited for the UI thread to be free before it could start. Reported // because the phases otherwise do not add up to the total, and the gap is the interesting - // part: it is the frame being held up by work that is not the frame's. + // part: the frame being held up by work that is not the frame's. waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION) @@ -123,7 +119,7 @@ private const val CAP = 20_000 * Records into [FrameStats] for as long as this screen is on it. * * The listener is what comes and goes; what it writes into does not, so a report covers the same - * stretch of time as the counters beside it. See [FrameStats]. + * stretch of time as the counters beside it. * * The listener is handed its own thread because the platform calls it for every frame and the * documentation is explicit that doing that on the main thread taxes the very thing being measured. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt index 9dc2597..b3fcefd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt @@ -47,12 +47,11 @@ data class SyntaxPalette( /** * [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * - * Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same - * code is the same colours wherever it appears. + * Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever + * it appears. * * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it - * off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text - * which needs no colour of its own -- the style the caller draws it with carries that. + * off the drawing thread. * * The timing is the number the highlighter is judged by: the library this replaced took **174ms** * on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted] @@ -82,8 +81,7 @@ fun highlight(code: String, language: Language?): AnnotatedString { * to the end of the code, which is also what it looks like while a fence is still being written. * * In ordinary code the order of recognition is comment, string, attribute, number, word, and - * finally a single punctuation or mark character. Punctuation and marks are coloured only in - * ordinary code, never inside a string or a comment. + * finally a single punctuation or mark character, which are coloured only in ordinary code. */ fun scan(code: String, rules: Rules): List = Scanner(code, rules).run() @@ -156,8 +154,8 @@ private class Scanner(private val code: String, private val rules: Rules) { at += comment.open.length var depth = 1 while (at < code.length && depth > 0) { - // The closer is tried first so that a language whose two delimiters are the same - // string -- CoffeeScript's `###` -- closes rather than nesting forever. + // The closer is tried first so that a language whose two delimiters are the same string + // -- CoffeeScript's `###` -- closes rather than nesting forever. if (starts(comment.close)) { depth-- at += comment.close.length diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 6f817e9..c8845c7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -49,11 +49,9 @@ private const val DELETING = "deleting" /** * What the rows further down a batch say while they wait their turn. * - * Its own word rather than the operation's, because it is its own state and the difference is the - * kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it - * exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row - * that still looked ordinary was still tappable, and tapping it would import it a second time - * behind the batch already coming for it. + * Its own word rather than the operation's, because nothing has been done to this session yet, so a + * batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over + * all the same -- a queued row that still looked ordinary was still tappable. */ private const val WAITING = "waiting" @@ -62,25 +60,22 @@ private const val WAITING = "waiting" * * A batch takes rows out of the list as each one lands, so everything below the one that went * slides up -- and a tap already on its way then arrives at whichever row moved into that place. On - * this screen that means importing a session nobody chose, which is not something a second tap can - * undo. + * this screen that means importing a session nobody chose. * * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be - * a flicker running down the list. Half a second: long enough to cover a tap already travelling - * when the row moved, short enough that it is not in the way of a deliberate one. + * a flicker running down the list. */ private const val SETTLE_MS = 500L /** * Continuing a Claude Code session the machine already has. * - * The list is the machine's answer, not this app's: it asks a setup what sessions it holds and - * shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this - * screen into a file reader. + * The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so + * an enrolled phone cannot turn this screen into a file reader. * * Holding a row selects it and puts the screen in selection mode, where the options that act on a - * selection appear along the bottom. That exists because these arrive in bulk — a machine - * accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason + * selection appear along the bottom. That exists because these arrive in bulk -- a machine + * accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason * clearing them out was not worth doing. */ @OptIn(ExperimentalFoundationApi::class) @@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio var chosen by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf>>(LoadState.Loading) } - // What is happening to each row right now, as the word the row shows: "importing" or - // "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from - // whatever the server last said and this belongs to the request rather than to the session -- - // the same arrangement the session list uses for its deletes. + // What is happening to each row right now, as the word the row shows. A map keyed by id rather + // than a flag per row, because the rows are rebuilt from whatever the server last said and this + // belongs to the request rather than to the session. var running by remember { mutableStateOf>(emptyMap()) } - // Which rows the reader has picked out. Empty means selection mode is off: there is no - // separate flag, because a selection mode with nothing selected is a state with no controls - // in it and no way to leave except Back. + // Which rows the reader has picked out. Empty means selection mode is off: a selection mode + // with nothing selected is a state with no controls in it and no way to leave except Back. var selected by remember { mutableStateOf>(emptySet()) } // Failures that belong to one row rather than to the screen, shown on that row. A batch is - // exactly where a single banner fails: nine deletes succeeded and one did not, and the - // banner cannot say which. + // exactly where a single banner fails: nine deletes succeeded and one did not, and the banner + // cannot say which. var rowErrors by remember { mutableStateOf>(emptyMap()) } // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // themselves, not a flag, so the dialog can say what it is about. var confirming by remember { mutableStateOf?>(null) } - // Same default as the spawn screen, and for the same reason: a phone - // is the wrong place to answer "allow Bash?" forty times. + // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty + // times. var permissionMode by remember { mutableStateOf("auto") } - // When each row last slid upwards, as a plain map rather than state: nothing is drawn from - // it, so a tap reading it needs no recomposition and there is no timer to cancel when a - // second removal lands on top of the first. + // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, + // so a tap reading it needs no recomposition. val movedAt = remember { mutableMapOf() } fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS @@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * Fetches the list and takes the row states from it. * * Taken from the answer rather than kept across the load: the server is what knows what is - * running, and this screen may be opening on work another screen -- or another phone -- - * started. Anything held locally would be a second version of that, and the stale one. + * running, and this screen may be opening on work another phone started. */ suspend fun fetchInto(setup: Setup): LoadState> = try { @@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * Hands [targets] to the server in one request, marking every row it covers. * * The request only *starts* the work -- the server runs it and says how each row went on the - * change stream, which is what lets this screen be left while a batch is still going. So there - * is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and - * everything after that arrives as an event. + * change stream, which is what lets this screen be left while a batch is still going. * * Marked [WAITING] rather than with the operation's own word until the server confirms. Between * the request leaving and the `started` event coming back, "we have asked" is the truth and "it - * is importing" is a guess -- and the row is inert either way, which is the part that matters. + * is importing" is a guess. * * The selection is dropped as the work is handed over, not when it finishes: the screen goes * back to how it started, and what says the work is happening is the rows it is happening to. @@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio val ids = targets.map { it.id } scope.launch { // One request for the whole batch, not one per row. Sent row by row, a handover was - // only as atomic as the network: the fourth of six could fail, or the screen could be - // left with two still unsent, and what came back was some rows running and some - // untouched -- indistinguishable, on the list, from rows nobody had picked. Now - // either the server has the batch or it has none of it, and this is the one place - // that can be true. + // only as atomic as the network, and what came back was some rows running and some + // untouched -- indistinguishable, on the list, from rows nobody had picked. try { withContext(Dispatchers.IO) { send(ids) } } catch (err: Exception) { // The server never took it, so nothing is running and no event will arrive to say - // so. This is the one failure the screen must report itself -- and it is now the - // whole batch's failure, which is the point: no row was singled out. + // so. This is the one failure the screen must report itself -- and it is the whole + // batch's failure, which is the point: no row was singled out. running = running - ids.toSet() rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" } return@launch @@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // Then ask what actually happened, if anything still looks outstanding. // // The change stream is a broadcast with no memory, so an operation that started and - // finished while it was still connecting is one nothing will ever be said about -- - // and the row sits marked for ever. That is not hypothetical: with responses held - // back far enough for the stream to open late, one row of a pair of deletes cleared - // and the other stayed on "waiting". + // finished while it was still connecting is one nothing will ever be said about -- and + // the row sits marked for ever. That is not hypothetical: with responses held back far + // enough, one row of a pair of deletes cleared and the other stayed on "waiting". // - // The listing is the repair, because it carries the same state the events do. Only - // when something still looks outstanding, so the ordinary case -- where the events - // arrived and the rows are already gone -- does not pay for a second listing, which - // is the most expensive call this screen makes. + // The listing is the repair, because it carries the same state the events do. Only when + // something still looks outstanding, so the ordinary case does not pay for a second + // listing, which is the most expensive call this screen makes. if (setup != null && targets.any { running.containsKey(it.id) }) { - // Quietly: no Loading, because blanking the list to report on rows that are - // already saying what is happening to them is the flicker this screen avoids - // everywhere else. + // Quietly: no Loading, because blanking the list to report on rows that are already + // saying what is happening to them is the flicker this screen avoids everywhere + // else. sessions = fetchInto(setup) } } @@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } - /** - * Imports [targets], and goes to the session it made when [thenOpen]. - * - * One function for the tap and for the bar, differing in that one flag: continuing a session - * and then looking at it is what a tap on a row means, and a batch has several results and no - * reason to pick one of them to become the screen. - */ /** Continues [targets] in the background, leaving the screen where it is. */ fun importAll(targets: List) { val setup = chosen ?: return @@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } - // Live changes to what the server is doing to these sessions, for as long as this screen is - // up. The listing already carried the same state when the screen opened -- this is what keeps - // it current afterwards, including for work another screen or another phone started. + // Live changes to what the server is doing to these sessions, for as long as this screen is up. + // The listing already carried the same state when the screen opened -- this is what keeps it + // current afterwards, including for work another phone started. // // Failures here are deliberately quiet. There is nothing for a reader to do about a dropped - // event stream, and nothing is lost by one: every state it would have carried is in the next - // listing, which is what Refresh and re-entering the tab already fetch. + // event stream, and every state it would have carried is in the next listing. val liveChanges = remember { java.util.concurrent.atomic.AtomicReference(null) } @@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio running = running + (change.session to (change.operation ?: WAITING)) // Gone from the machine either way: a delete removed the - // transcript, an import made it a session, and neither is - // something this list still has to offer. + // transcript, an import made it a session. "finished" -> { running = running - change.session forget(change.session) @@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } } catch (e: kotlinx.coroutines.CancellationException) { - // The screen leaving, not a failure -- and swallowing it would leave this - // loop reconnecting to a stream nobody is watching. + // The screen leaving, not a failure -- and swallowing it would leave this loop + // reconnecting to a stream nobody is watching. throw e } catch (_: Exception) { - // Retried below; the listing is the truth in the meantime. - // - // Any failure, not only an [ApiException]. A stream is an optimisation over - // the listing here, so nothing it can do is worth taking the app down for -- - // and catching only the failure that was expected means an unexpected one - // reaches the top of the app and closes it, from a screen that is merely - // loading a list. + // Retried below; the listing is the truth in the meantime. Any failure, not + // only an [ApiException]: a stream is an optimisation over the listing here, + // and catching only the expected failure means an unexpected one closes the app + // from a screen that is merely loading a list. } finally { stream.close() } @@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // Nested inside MainScreen's own handler, so it wins while there is a selection. BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } - // Measured rather than assumed: the list reserves exactly what the bar covers, so the last - // row can still be scrolled to while it is up, and nothing is nudged by a number that was - // right for one font size. + // Measured rather than assumed: the list reserves exactly what the bar covers, so the last row + // can still be scrolled to while it is up. var barHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current @@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } - // Beside nothing in particular, because a selection is not one row: the options that act - // on it belong to the screen, and the bottom is where a thumb already is. + // Beside nothing in particular, because a selection is not one row: the options that act on + // it belong to the screen, and the bottom is where a thumb already is. if (selected.isNotEmpty()) { val picked = (sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty() @@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * What can be done to the rows that are selected. * * Delete and Import only, for now: they are the two things this screen has ever done to a session, - * and an option that appears here has to work on every row in a selection rather than on the one - * somebody was thinking of. + * and an option that appears here has to work on every row in a selection. */ @Composable private fun SelectionBar( @@ -567,26 +537,23 @@ private fun ImportableList( Modifier.fillMaxWidth() .padding(vertical = 4.dp) .combinedClickable( - // Off while something is happening to this row -- - // see [BusyItem], which draws that but deliberately - // leaves the gestures alone so the list still - // scrolls. + // Off while something is happening to this row -- see + // [BusyItem], which draws that but leaves the gestures + // alone so the list still scrolls. enabled = running[session.id] == null, onClick = { if (settling(session.id)) return@combinedClickable // In selection mode a tap is a selection, so the - // reader is never one mis-tap away from starting - // a CLI they were only picking rows for. + // reader is never one mis-tap away from starting a + // CLI they were only picking rows for. // - // Outside it, a tap continues the session -- - // except on a row that cannot be continued, - // where it selects instead. That row's only - // remaining action is Delete, and a tap that - // did nothing at all would be a worse answer - // than one that offers the thing it can do. - // Two `--resume` processes on one transcript - // each replay the other's writes, which is why - // this must not simply try. + // Outside it, a tap continues the session -- except + // on a row that cannot be continued, where it + // selects instead. That row's only remaining action + // is Delete, and a tap that did nothing at all + // would be a worse answer. Two `--resume` processes + // on one transcript each replay the other's writes, + // which is why this must not simply try. if (selecting || session.inUse == "yes") onToggle(session) else onOpen(session) @@ -606,8 +573,7 @@ private fun ImportableList( Spacer(Modifier.width(8.dp)) // Beside the title, because "which one was I just in" is // the question this list answers and the order already - // reflects it -- the reader should be able to see the - // ordering they are being given rather than infer it. + // reflects it. Text( relativeTime(session.modified), style = MaterialTheme.typography.bodySmall, @@ -616,12 +582,11 @@ private fun ImportableList( } Spacer(Modifier.height(4.dp)) // The path first, and the only thing here that is cut: it is - // one long value with no natural break, where the lines below - // it are short enough to wrap readably. Cut at the head, - // because a path is identified by its tail and these all - // share a long prefix. By the row's real width rather than a - // character count, which was one guess for every font size - // and screen. + // one long value with no natural break. Cut at the head, + // because a path is identified by its tail and these all share + // a long prefix. By the row's real width rather than a + // character count, which was one guess for every font size and + // screen. session.cwd .takeIf { it.isNotEmpty() } ?.let { cwd -> @@ -648,8 +613,7 @@ private fun ImportableList( color = warningColor, ) } - // Reported where it happened, in the server's own words, the - // way every other failure in this app is shown. + // Reported where it happened, in the server's own words. errors[session.id]?.let { message -> Spacer(Modifier.height(4.dp)) Text( @@ -673,14 +637,14 @@ private fun statsOf(session: Importable): String = // Said, because a name and a last message are different claims: one describes the // session, the other is only what happened last in it. if (session.named) "named" else null, - // What continuing it costs, which is the question this list is really asked. First - // of the measurements for that reason, and absent rather than zero when nothing has - // been measured -- a session with no turns yet has no figure, not a figure of none. + // What continuing it costs, which is the question this list is really asked. Absent + // rather than zero when nothing has been measured -- a session with no turns yet has no + // figure, not a figure of none. session.contextTokens?.let { "${it / 1000}k context" }, "${session.lines} lines", // Kept beside the context figure because the two disagree usefully: most of a large - // transcript is history from before a compaction, which the model is no longer - // given, so a big file can be cheap to continue and a small one expensive. + // transcript is history from before a compaction, so a big file can be cheap to + // continue. humanSize(session.bytes), ) .joinToString(" · ") @@ -689,16 +653,14 @@ private fun statsOf(session: Importable): String = * Why this session might not be safe to take, if it isn't. * * Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind, - * and no shade distinguishes them. The colour is what makes it findable; the words are what make it - * actionable. + * and no shade distinguishes them. */ private fun warningOf(session: Importable): String? = when (session.inUse) { // What was measured is that a live process on that machine holds this session open. Which - // process is not measured, so it isn't claimed: "a terminal — close it there first" sent - // people looking for a window that need not exist. It is just as likely another agent, or - // this app on a session it spawned. Naming a place the reader then can't find turns a - // correct refusal into a wrong instruction. + // process is not measured, so it isn't claimed: "a terminal -- close it there first" sent + // people looking for a window that need not exist. Naming a place the reader then can't + // find turns a correct refusal into a wrong instruction. "yes" -> "something on that machine is running it" "unknown" -> "can't tell if it's open" else -> null diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt index 2c8d109..a1d54b1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt @@ -49,10 +49,9 @@ data class Rules( /** Tokens that open a comment running to the end of the line. */ val lineComments: List = emptyList(), /** - * Whether [lineComments] count only at the start of a word. - * - * The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those - * lines is one of the mistakes this scanner exists to stop. + * Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}` + * and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this + * scanner exists to stop. */ val lineCommentsAtWordStart: Boolean = false, val blockComment: BlockComment? = null, @@ -63,8 +62,8 @@ data class Rules( val rawStrings: Boolean = false, /** * Rust: `'` opens a character literal only when a backslash or one character and a `'` follow. - * Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a - * string that runs to the next apostrophe in the block. + * Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the + * next apostrophe in the block. */ val lifetimes: Boolean = false, ) @@ -91,11 +90,10 @@ enum class Attributes { * The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to * be made of. * - * Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES] - * and the one shared scanner in [scan]. Markdown has none of those, and what a character means - * there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]). - * That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS], - * and no caller learns which one it got. + * Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner. + * Markdown has none of those, and what a character means there depends on where on the line it + * sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a + * row of rules or an entry in [SCANNERS], and no caller learns which one it got. */ fun spansOf(code: String, language: Language): List = SCANNERS.getValue(language)(code) @@ -140,8 +138,8 @@ private val RULES: Map by lazy { blockComment = C_STYLE, quotes = listOf(DOUBLE, SINGLE), ), - // `###` opens and closes a block comment and `#` opens a line one, which is why the - // scanner tries the block opener first. + // `###` opens and closes a block comment and `#` opens a line one, which is why the scanner + // tries the block opener first. Language.COFFEESCRIPT to Rules( keywords = KEYWORDS_COFFEESCRIPT, @@ -162,8 +160,8 @@ private val RULES: Map by lazy { keywords = KEYWORDS_FISH, lineComments = listOf("#"), lineCommentsAtWordStart = true, - // fish's single quotes escape only `\'` and `\\`, which is what "skip the - // character after a backslash" already does. + // fish's single quotes escape only `\'` and `\\`, which is what "skip the character + // after a backslash" already does. quotes = listOf(DOUBLE, SINGLE), ), Language.GO to @@ -288,10 +286,9 @@ private val RULES: Map by lazy { * The keyword sets. * * Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0 - * (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is - * coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`, - * `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because - * the word scanner cannot reach them and the library only matched them by luck. + * (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns + * plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family, + * Ruby's `defined?` -- because the word scanner cannot reach them. */ private fun words(list: String): Set = list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt index 25362c0..e42aaaa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt @@ -8,7 +8,7 @@ package com.example.aiapp * empty list, which is the one wrong answer that looks like a right one. * * [Loading] and [Error] carry no payload, so they are `LoadState` and this is covariant in - * [T]: one `LoadState.Loading` serves every screen rather than each needing its own. + * [T]: one `LoadState.Loading` serves every screen. */ sealed class LoadState { data object Loading : LoadState() 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 c998e89..f16b3c5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout import androidx.core.view.WindowCompat class MainActivity : ComponentActivity() { - // Bumped whenever enrollment lands via an aiapp:// intent so the - // composition below re-reads the stored settings. + // Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the + // stored settings. private var settingsVersion by mutableIntStateOf(0) - // The session a notification tap asked for, or null if nothing has. The - // serial is what makes a second tap on the same session's notification a - // second request: without it the two compare equal and the composition - // below has nothing to react to. + // The session a notification tap asked for, or null if nothing has. The serial is what makes a + // second tap on the same session's notification a second request: without it the two compare + // equal and the composition below has nothing to react to. private var openRequest by mutableStateOf(null) private var opens = 0 @@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() { private var shareRequest by mutableStateOf(null) private var shares = 0 - // Registered up front since permission launchers must be registered - // before the activity reaches STARTED. + // Registered up front since permission launchers must be registered before the activity reaches + // STARTED. private val requestLocalNetworkPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} @@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() { * The service starts either way, and posts nothing if this is refused. * * Deliberately not gated on the answer: the permission can be granted later from Android's own - * settings, and a service that only ever started at the moment it was granted would then stay - * down until the app was launched again -- which is the case notifications exist to avoid. + * settings, and a service that only ever started at the moment it was granted would stay down + * until the app was launched again. */ private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} @@ -64,21 +63,17 @@ class MainActivity : ComponentActivity() { // Before anything else that could throw, so the first crash of a launch is caught too. installCrashLog(this) - // Transparent status bar on every version; the Surface below paints - // through underneath it and content insets itself. Same reasoning - // as dev-updater's MainActivity. + // 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() - // Dark status-bar icons only over a light background, decided from the scheme rather - // than fixed. It was hardcoded to `true` -- dark icons -- which was right against the - // default light surface and became unreadable the moment the app wore Catppuccin Mocha. - // Asking the colour means a future palette change cannot reintroduce that: whatever - // `background` becomes, the icons follow it. + // 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. WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = AiAppColors.background.luminance() > 0.5f - // Android 17+ silently drops local-network traffic without this; - // requested up front because a denial is invisible at the socket - // layer (it just times out). + // Android 17+ silently drops local-network traffic without this; requested up front because + // a denial is invisible at the socket layer (it just times out). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) } @@ -88,16 +83,14 @@ class MainActivity : ComponentActivity() { } handleIntent(intent) - // After enrollment, so a first launch that arrives with a token - // starts the service with something to connect to rather than - // stopping it and waiting for the next launch. + // After enrollment, so a first launch that arrives with a token starts the service with + // something to connect to rather than stopping it and waiting for the next launch. NotificationService.sync(this) setContent { // Selection colours with the theme rather than at each place text is drawn: the - // transcript is one selection container, and a selection that ran from a reply into - // the code block under it would otherwise change colour halfway. See - // [AiAppSelectionColors]. + // transcript is one selection container, and a selection that ran from a reply into the + // code block under it would otherwise change colour halfway. MaterialTheme(colorScheme = AiAppColors) { CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) { Surface(modifier = Modifier.fillMaxSize()) { @@ -107,8 +100,7 @@ class MainActivity : ComponentActivity() { // the frame's draw phase is where Compose's measurement lands, and // a report saying "draw is high" cannot otherwise say whether the // cost is the transcript or the chrome around it. The keyboard is - // the case that made it matter -- every frame of the IME animation - // relays out and re-records this whole box. + // the case that made it matter. Modifier.layout { measurable, constraints -> val started = System.nanoTime() val placeable = measurable.measure(constraints) @@ -135,19 +127,16 @@ class MainActivity : ComponentActivity() { } .fillMaxSize() .statusBarsPadding() - // The gesture strip at the bottom of most - // phones. Without it the send row sits under - // the swipe area, where a tap is as likely to - // navigate away as to press a button. + // The gesture strip at the bottom of most phones. Without it + // the send row sits under the swipe area, where a tap is as + // likely to navigate away as to press a button. // // No imePadding here, deliberately: applied at the root it // resizes this whole box on every frame of the keyboard // animation, which re-measures, re-places and re-records every - // screen's entire tree per frame -- measured above as most of - // the frame budget. Each screen takes the keyboard itself - // (AppRoot wraps the ordinary ones; the session screen moves - // only its composer and transcript), so the per-frame cost is - // scoped to what actually moves. + // screen's entire tree per frame. Each screen takes the + // keyboard itself, so the per-frame cost is scoped to what + // actually moves. .navigationBarsPadding() ) { AppRoot(settingsVersion, openRequest, shareRequest) @@ -158,9 +147,8 @@ class MainActivity : ComponentActivity() { } } - // launchMode="singleTop": an enrollment scan, or a notification tapped - // while the app is open, lands here rather than in a second activity - // instance. + // 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) { super.onNewIntent(intent) handleIntent(intent) @@ -171,8 +159,7 @@ class MainActivity : ComponentActivity() { * * Three things arrive this way -- a share from another app, and an `aiapp://` URI that is * either an enrollment code or a notification naming a session. The URIs are told apart by host - * rather than by two entry points, so a further kind is a branch here rather than another - * intent to remember to handle. + * rather than by two entry points, so a further kind is a branch here. */ private fun handleIntent(intent: Intent?) { intent ?: return @@ -195,8 +182,8 @@ class MainActivity : ComponentActivity() { } saveServerSettings(this, settings) settingsVersion++ - // Enrolling is the moment there is a backend to watch, and - // re-enrolling elsewhere is the moment the old one stops being it. + // Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the + // moment the old one stops being it. NotificationService.sync(this) Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show() } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index a4f8596..f18ef22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -29,12 +29,10 @@ import androidx.lifecycle.repeatOnLifecycle * The app's root: one title, and four views of the backend behind it. * * These were four screens reached by four words in a row under the title, and the row was already - * full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say - * the same thing in less space and say one more thing besides: that these are places to be rather - * than errands to run. Sessions, the machine's importable history, the models on it and the - * machines themselves are all *the same backend*, looked at four ways, and none of them is a step - * down from another. Settings still is a step down, which is why it stays a pushed screen and keeps - * its own Back. + * full. Tabs say the same thing in less space and say one more thing besides: that these are places + * to be rather than errands to run. Sessions, the machine's importable history, the models on it + * and the machines themselves are all *the same backend*, looked at four ways, and none is a step + * down from another. Settings still is, which is why it stays a pushed screen with its own Back. */ private enum class MainTab(val label: String) { Sessions("Sessions"), @@ -61,17 +59,12 @@ fun MainScreen( // // What these four draw is a snapshot of a backend they are not connected to, so it is only as // fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A - // phone that was away while the tunnel was down, or that fetched before the network came up, - // came back to "Couldn't reach the server" sitting at the top of a list the server would now - // answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale - // failure is worse than a stale list: it is a claim about right now. + // phone that was away while the tunnel was down came back to "Couldn't reach the server" + // sitting at the top of a list the server would now answer for perfectly well. A stale failure + // is worse than a stale list: it is a claim about right now. // // Through the same token the Refresh button uses, so this is one instruction the tabs already - // understand rather than a second path into each of them -- which is also what makes it cover - // all four rather than the one the report came from. - // - // Not on the first entry: the tab composing already asks, and bumping here would make every - // cold start fetch twice. + // understand. Not on the first entry: the tab composing already asks. val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { var opening = true @@ -81,9 +74,8 @@ fun MainScreen( } } - // A tab the app put over the list has to step back to it rather than fall through to the - // system default, which closes the app -- that reads as a crash to somebody who only meant to - // get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled. + // A tab the app put over the list has to step back to it rather than fall through to the system + // default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled. BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } Column(Modifier.fillMaxSize()) { @@ -98,20 +90,18 @@ fun MainScreen( ) // Glyphs rather than the words they replaced: neither ever changes, both are read // faster than they are spelled, and together they take the width that let the title - // keep its own line. They sit on the title's row because they act on the whole - // screen -- everything below this row is one tab's business, and a control belongs - // with the thing it acts on. - // Flush against each other: a glyph button carries its own padding, so two of them - // side by side already have two rings between their marks and one ring plus this - // row's padding to the screen edge. + // keep its own line. They sit on the title's row because they act on the whole screen. + // + // Flush against each other: a glyph button carries its own padding, so two side by side + // already have two rings between their marks. Row { GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) } } - // What is waiting to be attached, and what to do about it. Said here because the list - // below is where the choice is made, and a share that arrived with nothing on screen - // saying so would read as a tap that did nothing. + // What is waiting to be attached, and what to do about it. Said here because the list below + // is where the choice is made, and a share that arrived with nothing on screen saying so + // would read as a tap that did nothing. share?.let { Text( it.summary() + " -- open the session it belongs in.", @@ -127,8 +117,8 @@ fun MainScreen( .padding(12.dp), ) } - // Primary rather than the plain TabRow, which is deprecated in favour of the two that - // say where they sit: these are the app's top-level destinations. + // Primary rather than the plain TabRow, which is deprecated in favour of the two that say + // where they sit: these are the app's top-level destinations. PrimaryTabRow(selectedTabIndex = tab.ordinal) { MainTab.entries.forEach { entry -> Tab( @@ -139,10 +129,9 @@ fun MainScreen( } } - // Refreshing means "ask again about what I am looking at", so the button feeds the tab - // that is showing. The token from above means something else already changed what these - // show; the two are the same instruction to the tab below, so they are summed rather than - // tracked apart -- either one moving moves the sum, which is all a tab watches. + // Refreshing means "ask again about what I am looking at", so the button feeds the tab that + // is showing. The token from above means something else already changed what these show; + // the two are the same instruction, so they are summed rather than tracked apart. val token = reloadToken + refreshToken when (tab) { MainTab.Sessions -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 1509efd..07512dd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * [live] is the reply still arriving, and two things are different for it. Its parse is incremental * -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole - * message. And its pieces get a layer each: when drawing is invalidated, only the piece that - * changed is re-recorded instead of the whole reply, which is worth a great deal while every delta - * invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing - * once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 - * times in fifty seconds of reading -- and it is not free: each layer is a layout node and a - * display list held for the life of the row, and live node count is what the per-frame cost of the - * transcript scales with. + * message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is + * worth a great deal while every delta invalidates the message and worth nothing once it stops + * changing -- and it is not free: each layer is a layout node and a display list held for the life + * of the row, and live node count is what the transcript's per-frame cost scales with. */ @Composable fun MarkdownText( @@ -92,8 +89,8 @@ fun MarkdownText( var previousSegment: Segment? = null segments.forEachIndexed { at, segment -> val nextContinues = segments.getOrNull(at + 1)?.continues == true - // Only the tail is still being written; a frozen segment is finished text that - // happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot]. + // Only the tail is still being written; a frozen segment is finished text that happens + // to sit in a live reply, and it takes its colours now. MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) { segment.pieces.forEachIndexed { index, piece -> val gap = @@ -103,9 +100,9 @@ fun MarkdownText( if (segment.continues) 0.dp else BLOCK_SPACING else -> gapBefore(previous, piece) } - // Keyed by where the piece starts in the message rather than by its position - // in this column, so a delta landing in the last block leaves every other - // piece's composition alone -- and a block keeps its key when it freezes. + // Keyed by where the piece starts in the message rather than by its position in + // this column, so a delta landing in the last block leaves every other piece's + // composition alone -- and a block keeps its key when it freezes. key(segment.start, piece) { MarkdownPiece( segment.parse, @@ -137,8 +134,7 @@ fun MarkdownText( * A stretch of a message with a parse of its own: the whole of a settled message, or one block, the * finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins * in the message. [continues] says the first piece is an item of the list the segment before it - * ended with, so the two draw as one list: no block gap between them, and neither the item above - * the seam nor the one below it takes the padding of a list's edge. + * ended with, so the two draw as one list. */ private class Segment( val text: String, @@ -154,14 +150,11 @@ private class Segment( * * The first parse has to be inline. The renderer's own asynchronous path draws an empty loading * slot until its result arrives, so a row is measured at nothing before it is measured at its real - * height, and the transcript above it collapses and springs back. Seen with five replies on screen - * at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment - * later it was all there again. That is the "skipping up and down" this list must never do. + * height, and the transcript above it collapses and springs back -- seen with five replies on + * screen at once, the whole conversation shrunk to fit a single screen. * * Every parse after the first is off the composing thread, and the row keeps drawing the parse it - * already has until the new one lands, so there is never a frame without a height. What is on - * screen is always a real prefix of the reply rather than a guess at it; it is simply one parse - * behind. + * already has until the new one lands, so there is never a frame without a height. */ @Composable private fun liveSegments(text: String): List { @@ -187,24 +180,18 @@ private fun liveSegments(text: String): List { * Reparsing the whole message per delta was fine for a short reply and not for a long one: a * twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran * off the composing thread it was every core busy while the frame's own thread waited for one. - * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is - * finished -- nothing appended later can reach back into it, since a paragraph ends at the blank - * line or the block that interrupts it, a fence at its closing fence, a list at the first line that - * is neither an item nor indented under one. So every block but the last is [frozen] with the parse - * that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed - * again. * - * A list is cut once more, at its last item, by the same reasoning one level down: an item is - * finished once the next item has begun, since a line can only continue the item it is indented - * under or start a new one. Without this a reply that is one long list -- forty sources -- parsed - * the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on - * has to have begun in earnest: a bare `-` is an empty item now and the first character of a - * paragraph line once `-x` arrives, and cutting on it would draw that line as a new item. + * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is + * finished -- nothing appended later can reach back into it. So every block but the last is + * [frozen] with the parse that finished it, and only the tail is parsed again. + * + * A list is cut once more, at its last item, by the same reasoning one level down. Without this a + * reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut + * lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of + * a paragraph line once `-x` arrives. * * What the cut gives up is one thing: a reference definition arriving later than a link that uses - * it, since the frozen block's parse never sees it. The link draws as its brackets until the reply - * settles and is parsed whole by [warm], which is the same moment every other transient of - * streaming is put right. + * it. The link draws as its brackets until the reply settles and is parsed whole by [warm]. */ private class LiveParse( val text: String, @@ -217,8 +204,8 @@ private class LiveParse( get() = frozen + tail fun advanceTo(next: String): LiveParse { - // Anything but an append to what was frozen -- a message replaced, a stream reset -- - // starts over. + // Anything but an append to what was frozen -- a message replaced, a stream reset -- starts + // over. if (!next.regionMatches(0, text, 0, consumed)) return whole(next) val tailText = next.substring(consumed) val parse = parseMarkdown(tailText) @@ -264,8 +251,7 @@ private class LiveParse( /** * The piece of the tail still being written: the last item of a list of several, or the first - * piece of the last block when there is more than one block. Null when nothing before it is - * finished, so the tail stays whole. + * piece of the last block when there is more than one. Null when nothing before it is finished. */ private fun openPiece(parse: State.Success, all: List): Piece? { val last = all.lastOrNull() ?: return null @@ -315,28 +301,24 @@ fun MarkdownPiece( * The renderer's own environment -- its colours, type scale, dimensions, component table and * reference links -- around whatever draws pieces of [parse]. * - * The parsing is the library's. Markdown is somebody else's specification, and a hand-written + * The parsing is the library's: markdown is somebody else's specification, and a hand-written * parser would get the edge cases wrong one case at a time. So is the environment: the element * composables its dispatch reaches read these locals, and providing them once here is what lets a * piece be drawn anywhere -- in a message's column, or as one item of the transcript list. - * Everything below this is the mapping onto the app's palette and type scale. * * The locals are provided directly rather than through the renderer's `Markdown()` composable, - * which was the last of its composables on the hot path and was here only to provide them. What - * that buys is that nothing between a piece and the screen is the library's but the leaf - * composables named in the component table, so a different parser could stand behind [State] - * without the renderer's entry point being involved. + * which was the last of its composables on the hot path and was here only to provide them. So + * nothing between a piece and the screen is the library's but the leaf composables named in the + * component table. * - * Colours come from the theme rather than from the renderer's defaults, so code, links and rules - * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. + * Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its + * own. * * [streaming] says this parse is the part of a reply still being written, which only the fences - * care about: lexing is proportional to how much code there is, and a fence still arriving is - * re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin - * fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on - * text that was being replaced as fast as they were computed. So a fence still being written is - * drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse] - * already makes for a reference link defined at the foot of a message. + * care about: lexing is proportional to how much code there is. Measured streaming a two-hundred- + * line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- + * for colours on text being replaced as fast as they were computed. So a fence still being written + * is drawn plain and takes its colours when the block freezes. */ @Composable private fun MarkdownRoot( @@ -354,45 +336,40 @@ private fun MarkdownRoot( CompositionLocalProvider( LocalReferenceLinkHandler provides parse.referenceLinkHandler, LocalMarkdownPadding provides markdownPadding(), - // Read by the renderer's own text composable, which no paragraph reaches any more, and - // by its checkbox. Provided so a path that does reach them draws no image rather than - // failing to compose. + // Read by the renderer's own text composable, which no paragraph reaches any more, and by + // its checkbox. Provided so a path that does reach them draws no image rather than failing + // to compose. LocalImageTransformer provides remember { NoOpImageTransformerImpl() }, LocalMarkdownAnimations provides markdownAnimations(), LocalMarkdownColors provides markdownColor( text = MaterialTheme.colorScheme.onSurface, dividerColor = MaterialTheme.colorScheme.outlineVariant, - // The dark surface every verbatim thing in this app sits on -- see [rawSurface], - // and the tool call above this reply, which now matches. `surfaceVariant` was - // exactly a card's own fill, so a fenced block inside a tool call had no - // background at all and one in a reply read as a step *up* out of the page. + // The dark surface every verbatim thing in this app sits on -- and the tool call + // above this reply, which now matches. `surfaceVariant` was exactly a card's own + // fill, so a fenced block inside a tool call had no background at all. codeBackground = rawSurface, // The same colour. Not drawn by the renderer as a span background but by - // [LinkedText] behind the text, so a selection lands on top of it as it does on a - // fenced block -- see `appendCodeChip`. + // [LinkedText] behind the text, so a selection lands on top of it -- see + // `appendCodeChip`. inlineCodeBackground = rawSurface, - // The same tint a code block gets, rather than the renderer's 2%-alpha default: - // two adjacent tints that differ by a fiftieth read as one flat block on a phone, - // so the table would have had a border-less grid and nothing saying where it began. + // The same tint a code block gets, rather than the renderer's 2%-alpha default: two + // adjacent tints that differ by a fiftieth read as one flat block on a phone. tableBackground = MaterialTheme.colorScheme.surfaceVariant, ), LocalMarkdownTypography provides markdownTypography( // A ladder that starts near the body text and descends, because these are headings // inside a chat message rather than the top of a document. The renderer's defaults - // are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which - // is bigger than this app's own screen titles and reads as the reply shouting. - // - // Every step is a different size, so two levels of nesting never draw the same: - // one clear step per level is the whole job of a heading. + // are the Material *display* styles -- `#` came out at 57sp, bigger than this app's + // own screen titles. Every step is a different size, so two levels of nesting never + // draw the same. h1 = MaterialTheme.typography.headlineSmall, h2 = MaterialTheme.typography.titleLarge, h3 = MaterialTheme.typography.titleMedium, h4 = MaterialTheme.typography.titleSmall, h5 = MaterialTheme.typography.labelMedium, h6 = MaterialTheme.typography.labelSmall, - // Body text at the size everything else in the transcript uses. text = body, paragraph = body, ordered = body, @@ -400,16 +377,10 @@ private fun MarkdownRoot( list = body, table = body, // Code in a monospace face, in the ordinary text colour. The face and the tinted - // background are what say "this is code"; colour is not, and it used to be green - // -- the palette's colour for a *literal*. A block of code is not a literal, it - // is text that happens to be code, and painting all of it green said the whole - // block was one. Where a literal really does appear inside code, the thing that - // should colour it is a syntax highlighter looking at the code, which is exactly - // what a tool call's input already gets from `catppuccinSyntax`. - // - // The colour rides on the style here rather than in `markdownColor`, which - // stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved - // them onto the typography. + // background are what say "this is code"; colour is not, and it used to be green -- + // the palette's colour for a *literal*. A block of code is not a literal, and + // painting all of it green said the whole block was one. Where a literal really + // does appear inside code, what should colour it is a syntax highlighter. code = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, @@ -436,31 +407,26 @@ private fun MarkdownRoot( LocalMarkdownDimens provides markdownDimens( // Half the renderer's 16dp. Padding is charged on both sides of every cell, so at - // the default a fifth of the narrowest column went on space rather than on words - // -- and the narrowest column is where the wrapping below has the least room. + // the default a fifth of the narrowest column went on space rather than on words. tableCellPadding = 8.dp, // What a column narrows to before the table starts scrolling sideways instead. It // is the floor, not the width: a table with room to spare spreads across it. // // Down from the renderer's 160dp, and the number is a measurement rather than a // taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp - // makes even a three-column table -- the commonest shape there is -- scroll, while - // 136dp fits three across the phone this app is read on. Four and up still scroll, - // which is the right answer for genuinely too many columns: squeezing six columns - // into a phone would give every cell one word per line. - // - // Narrower would fit more, and stop being readable. This is the widest minimum - // that keeps three columns on screen, which is the trade the number is making. + // makes even a three-column table scroll, while 136dp fits three across the phone + // this app is read on. Four and up still scroll, which is the right answer for + // genuinely too many columns. This is the widest minimum that keeps three on + // screen. tableCellWidth = 136.dp, ), LocalMarkdownComponents provides markdownComponents( // The m3 renderer's own default, restored: supplying `components` at all replaces - // the whole set, and this is the only member of it the Material layer overrides. + // the whole set, and this is the only member the Material layer overrides. checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, // Everything that draws a run of text, so a link is a span rather than a node -- - // see [LinkedText]. Setext headings take the same styles as `#` and `##`, which - // is the renderer's own pairing. + // see [LinkedText]. Setext headings take the same styles as `#` and `##`. text = { LinkedText(it, it.typography.text) }, paragraph = { LinkedText(it, it.typography.paragraph) }, heading1 = { LinkedHeading(it, it.typography.h1) }, @@ -471,8 +437,8 @@ private fun MarkdownRoot( heading6 = { LinkedHeading(it, it.typography.h6) }, setextHeading1 = { LinkedHeading(it, it.typography.h1) }, setextHeading2 = { LinkedHeading(it, it.typography.h2) }, - // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- - // so they draw like the top-level ones the transcript cuts into items. + // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so + // they draw like the top-level ones the transcript cuts into items. orderedList = { MarkdownList(it.content, it.node, it.listDepth) }, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, table = { LinkedTable(it.content, it.node, it.typography.table) }, @@ -491,14 +457,12 @@ private fun MarkdownRoot( /** * A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need. * - * Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least - * columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it - * scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`, - * which is a subcomposition; here it is one layout modifier, and the trick is where it sits. - * `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that - * minimum through to its content while lifting the maximum to unbounded, and the modifier after it - * reads the minimum back as the room and sizes the rows to the larger of that and the floor. The - * scroll then has exactly the overflow to scroll, which is none when the table fits. + * Each column has a floor, so the table is at least columns-times-floor wide; narrower than the + * room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The + * renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one + * layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal + * scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after + * it reads the minimum back and sizes the rows to the larger of that and the floor. */ @Composable private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { @@ -539,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { * One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText]. * * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means - * most of a table is simply not readable: anything past about twenty characters ends in "..." with - * no way to see the rest, and an elided cell looks like a short one, so a table of measurements - * reads as a table of plausible shorter measurements. And they draw a link in a cell as its own - * layout node, the cost [LinkedText] exists to avoid. + * most of a table is simply not readable: an elided cell looks like a short one, so a table of + * measurements reads as a table of plausible shorter measurements. And they draw a link in a cell + * as its own layout node, the cost [LinkedText] exists to avoid. * * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell - * beside a one-line one centred the short one against the middle of the tall one and lost the line - * the reader was reading across. What the wrapping does *not* do is make a wide table fit; - * [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a - * six-column table into the width of a phone would give every cell one word per line. + * beside a one-line one centred the short one against the middle of the tall one. What the wrapping + * does *not* do is make a wide table fit; [LinkedTable] scrolls it instead. * - * The semantics are the renderer's: each cell is an item of the table's collection, and a header - * cell is a heading. + * The semantics are the renderer's: each cell is an item of the table's collection. */ @Composable private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) { @@ -587,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI * Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much * was written. Measured against a real Claude Code transcript on the emulator, one message took * **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first - * tuned on -- so a page of history landing composed several rows that each stalled the frame they - * appeared in. That is the lag when a block loads. + * tuned on -- so a page of history landing composed several rows that each stalled the frame. * - * Nothing here changes what a row does when it has no answer waiting: it parses inline, on the - * composing thread, because a row measured at nothing before it is measured at its real height - * collapses the transcript above it. The point is only that by the time the reader scrolls to a - * row, the answer is usually already made -- [warm] runs on a background thread as each page of - * history arrives, which is seconds before anybody reaches the rows it brought. + * Nothing here changes what a row does when it has no answer waiting: it parses inline, because a + * row measured at nothing before its real height collapses the transcript above it. The point is + * only that by the time the reader scrolls to a row, the answer is usually already made. * * A miss is not stored, and that is what bounds this: the map holds one entry per message a page - * warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of - * itself on the way to being finished. It is dropped with the screen, and emptied by the stream - * reset that drops the rows it describes. + * warmed, so a reply still streaming cannot fill it with hundreds of copies of itself. */ @Stable class ParsedReplies { @@ -607,14 +562,13 @@ class ParsedReplies { /** * How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per - * fold, and walking the tree again each time is proportional to the message where a lookup is - * proportional to nothing. + * fold, and walking the tree again each time is proportional to the message. */ private val pieces = ConcurrentHashMap>() /** - * How each message divides into prose and memory notes, cached for the same reason as - * [piecesOf]: the regex scan behind [messageParts] is proportional to the message. + * How each message divides into prose and memory notes, cached for the same reason: the regex + * scan behind [messageParts] is proportional to the message. */ private val parts = ConcurrentHashMap>() @@ -627,7 +581,7 @@ class ParsedReplies { * much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator -- * and a lazy list drops the composition of a block that scrolls away, so a `remember` inside * the fence paid that again every time the reader came back to it. Six times in one scroll, - * measured. [warm] fills this off the drawing thread before the row is reached. + * measured. */ private val highlights = ConcurrentHashMap() @@ -649,11 +603,10 @@ class ParsedReplies { * Whether [warm] has made everything drawing [text] as pieces will look up. * * What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole - * message and the flatten runs on the composing thread -- so a reply not marked yet stays - * whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An - * explicit mark rather than a peek into the parse cache, because a message with memory notes is - * warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the - * cache left exactly that message unsplittable forever, re-warmed on every fold. + * message and the flatten runs on the composing thread, so a reply not marked yet stays whole + * until the screen has warmed it. An explicit mark rather than a peek into the parse cache, + * because a message with memory notes is warmed as its *parts*: nothing ever parses its full + * text, and inferring readiness from the cache left exactly that message unsplittable forever. */ fun splitReady(text: String): Boolean = text in ready @@ -668,9 +621,8 @@ class ParsedReplies { } /** - * [code] coloured for [language] -- the answer made ahead, or one made now. - * - * The key carries the language, because the same code lexes differently under two of them. + * [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the + * language, because the same code lexes differently under two of them. */ fun highlighted(code: String, language: Language?): AnnotatedString = if (language == null) AnnotatedString(code) @@ -686,9 +638,9 @@ class ParsedReplies { * * Suspending, and yielding between messages, because "off the composing thread" is not the same * as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in - * a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is - * every core busy, with the frame's own thread waiting for one. That showed up as 21ms of - * `waited` at the 90th percentile: the frame could not start, rather than taking too long. + * a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every + * core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th + * percentile. */ suspend fun warm(texts: List) { texts.forEach { text -> @@ -697,9 +649,8 @@ class ParsedReplies { DebugStats.timed("markdown warmed") { parseMarkdown(it) } } // The fences too, and here rather than in a pass of its own: they are found in the - // parse this just made, and lexing one is the same kind of cost as parsing the - // message it is in -- proportional to what was written, and charged to the frame - // that first draws it if nobody paid it earlier. + // parse this just made, and lexing one is the same kind of cost as parsing the message + // it is in. fences(parse).forEach { (code, language) -> highlighted(code, language) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt index 9ea7ca5..417fb86 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -44,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text - * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those - * annotations per link. Measured on the emulator against the same paragraphs with each link - * replaced by its label and address as plain words -- *more* text, the same gestures -- the linked - * version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a - * Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it. + * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation + * per link. Measured on the emulator against the same paragraphs with each link replaced by its + * label and address as plain words -- *more* text, the same gestures -- the linked version cost + * five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. * * Here a link is the link colour and underline, a string annotation carrying its address, and one * tap detector for the whole text that asks the layout which character was under the finger. What * that gives up is a link being its own accessibility node with a pressed state; the app's link * style never defined a pressed style, so nothing visible changes. * - * Every block the renderer dispatches through its component table comes here, which includes the - * paragraphs inside lists, quotes and alerts, and so does every table cell through - * [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it - * resolves those against its definitions. + * Every block the renderer dispatches through its component table comes here, and so does every + * table cell. Reference-style links are the one kind still drawn the renderer's way. * * An image is a link too, carrying its alt text. The app has no image loader and the renderer's * transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the - * model put something, with no sign of what fell out. The link says what was there and where, and - * opens it. It also means no paragraph needs the renderer's own text composable, which existed to - * place inline images and charged every paragraph for the possibility. + * model put something. The link says what was there and where, and opens it. */ @Composable fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { @@ -74,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { /** * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it - * does not know, so handed the heading node itself it draws an empty line. Which is what this did - * for a week. + * does not know, so handed the heading node itself it draws an empty line. */ @Composable fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) { @@ -113,18 +107,18 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif BasicText( text = text, modifier = - // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the - // second one has to be answered from inside the text rather than left to the card. + // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second + // one has to be answered from inside the text rather than left to the card. modifier.then(chipFill).pointerInput(text, onPlainTap) { awaitEachGesture { // Unconsumed is not required: something outside may already be tracking this // press, and it is still the press that may land on a link. awaitFirstDown(requireUnconsumed = false) - // A tap and nothing else. Null when the gesture became something somebody - // else's -- a scroll, or a press held past the long-press timeout, which is - // how a selection starts. The timeout is the load-bearing half: without it a - // press held for a second and released was still an up with nothing consumed, - // so holding a peer message to select from it shut the card instead. + // A tap and nothing else. Null when the gesture became somebody else's -- a + // scroll, or a press held past the long-press timeout, which is how a selection + // starts. The timeout is the load-bearing half: without it a press held for a + // second and released was still an up with nothing consumed, so holding a peer + // message to select from it shut the card instead. val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() @@ -156,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif * usually -- or null where a plain tap means nothing. * * A composition local because there is nowhere else to put it. The paragraphs of a message are - * composed by the renderer's own dispatch out of its component table, so nothing between a card and - * the text inside it is ours to pass a parameter through; the renderer already hands its colours, - * its typography and its components down the same way. + * composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours + * to pass a parameter through. * * It exists because a pointer-input node over the glyphs takes the tap and the card's own click - * handler never sees it. Measured on the emulator against an opened peer message: with a handler on - * the text -- consuming or not -- a tap on its words did nothing at all, and with the handler - * removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by - * pressing its words unless the words do the shutting, and "nothing happens when I press it" is - * indistinguishable from a card that has stopped working. + * handler never sees it. Measured against an opened peer message: with a handler on the text -- + * consuming or not -- a tap on its words did nothing at all, and with the handler removed the same + * tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless + * the words do the shutting. * - * Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh - * lambda per composition would invalidate every paragraph reading it. + * Provided as a value that outlives a recomposition, since a fresh lambda per composition would + * invalidate every paragraph reading it. */ val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } /** - * [onTap] as a stable value to provide for [LocalMarkdownTap]. - * - * The identity stays put while the behaviour follows the latest [onTap], which is what keeps - * providing it from invalidating the text under it on every recomposition of the card. + * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the + * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text + * under it on every recomposition of the card. */ @Composable fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit { @@ -212,9 +203,8 @@ private const val LINK_URL = "url" * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's * background is part of the text's own drawing, and the text node draws the selection first and the * glyphs over it, so a chip painted as a span background covered the selection: selecting a - * sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier - * on the text is under both, which is where a fenced block's box already is and why one of those - * always looked right. The [CODE_CHIP] annotation is what says where the fill goes. + * sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the + * text is under both, which is where a fenced block's box already is. */ private fun appendCodeChip( builder: AnnotatedString.Builder, @@ -242,13 +232,11 @@ private const val CODE_CHIP = "code" * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space - * stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the - * chip sticking a space out past the end of a selected line. It is also what leaves nothing behind - * when the only thing to reach a line is the space a chip is padded with. + * stops being drawn -- the same rule the selection rectangle obeys, so the two agree. * * A run's extent is taken from the boxes of its first and last characters, which is exact while a * line reads in one direction; mixed directions inside a code span would draw one box across the - * whole run rather than one per direction, and code spans are code. + * whole run, and code spans are code. */ private fun TextLayoutResult.chipRects(start: Int, end: Int): List { val rects = mutableListOf() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt index c21ffc0..fd62492 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt @@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and * is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a - * hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item - * has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still - * being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a - * table, one bullet: bounded, so both costs are. + * hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on + * a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one + * message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are. * - * Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a - * table and a nested list are each one node whatever is inside them, so nothing is ever split down - * the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty - * items -- so it is cut once more, into its items, and a nested list stays inside the item that - * holds it. + * Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a + * nested list are each one node whatever is inside them. A list is the one block that is not + * bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its + * items. * - * A piece is an *address* into the message's one parse ([block] indexes the root's children, [item] - * the list items of that child) rather than a substring of the message. Every piece of a message is - * drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a - * reference definition at its foot still resolves the links above it -- the two costs of cutting a - * message into strings and parsing each on its own. + * A piece is an *address* into the message's one parse rather than a substring of it. Every piece + * is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and + * a reference definition at its foot still resolves the links above it. */ @Immutable data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { @@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { } /** - * The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the - * newlines -- are not pieces. + * The pieces of [parse], in reading order. Blank nodes between blocks are not pieces. * * A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a * message that drew as nothing would be a hole in the transcript with no sign of what fell out. @@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp = val BLOCK_SPACING: Dp = 6.dp /** - * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the - * theme, the components and the reference links to the renderer's element composables. + * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme, + * the components and the reference links to the renderer's element composables. * - * A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph - * or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here - * through [MarkdownList]. Only the list item is drawn directly, because a list item is the one - * piece the renderer has no element for. + * A whole block goes to the renderer's own dispatch with this app's component table. Only the list + * item is drawn directly, because a list item is the one piece the renderer has no element for. * - * [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see - * `LiveParse`): an item that is the first or last of its own parse but not of the list the reader - * sees keeps an inner item's padding, so nothing moves when the seam between segments does. + * [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an + * item that is the first or last of its own parse but not of the list the reader sees keeps an + * inner item's padding, so nothing moves when the seam between segments does. */ @Composable fun MarkdownPiece( @@ -112,8 +105,8 @@ fun MarkdownPiece( listContinues: Boolean = false, ) { if (parse !is State.Success) { - // The parser threw. Nothing else in the app has seen this happen; if it does, the words - // are still worth more than a blank. + // The parser threw. Nothing else in the app has seen this happen; if it does, the words are + // still worth more than a blank. Text(text, modifier, style = MaterialTheme.typography.bodyLarge) return } @@ -144,8 +137,7 @@ fun MarkdownPiece( /** * A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a - * list inside a quote, and the nested lists an item holds. Top-level lists never come here; they - * are drawn an item at a time as pieces. + * list inside a quote, and the nested lists an item holds. Top-level lists never come here. */ @Composable fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) { @@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier * list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first * and last items, since there is no list column to carry it. * - * The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here - * rather than by a handler because it is the thing a reader might one day want styled -- a - * different glyph per depth, a colour -- and this is the one place it is drawn. + * The marker is drawn here rather than by a handler because it is the thing a reader might one day + * want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn. */ @Composable private fun MarkdownListItem( @@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) { /** * The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a * browser draws, so a nested list is told from its parent by the glyph as well as by the indent. - * Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the - * platform lacks draws as a box, and that check is the price of adding one here. + * Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check + * is the price of adding one here. */ private val BULLETS = listOf("• ", "◦ ", "▪ ") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt index 470438a..d82f06b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -7,23 +7,19 @@ package com.example.aiapp * Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings: * what a character means depends on where it sits. A `#` opens a heading at the start of a line and * is an ordinary character three words in; a `*` opens emphasis only if something closes it on the - * same line. The token scanner cannot ask either question, and answering them with its rules is how - * a highlighter comes to grey out the second half of a paragraph. + * same line. The token scanner cannot ask either question. * - * Structure is read a line at a time and each line's prose is then read left to right, so every - * decision is made inside one line -- except the two things that are not one line. A fenced block - * is state carried forward, so an unclosed fence colours the rest of the text, which is also what - * it looks like while somebody is still writing it. A table is found by its delimiter row - * (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the - * line before that -- the one place here that looks ahead. + * Structure is read a line at a time and each line's prose left to right, so every decision is made + * inside one line -- except the two that are not. A fenced block is state carried forward, so an + * unclosed fence colours the rest of the text, which is what it looks like while somebody is + * writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot + * be anything else, and its header is the line before that -- the one place here that looks ahead. * * What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is - * one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart - * by what came before rather than by the line itself. Colouring the wrong one of those as code is a - * mistake the reader cannot see, so both are left plain, which is the safe answer. + * one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by + * what came before. Colouring the wrong one as code is a mistake the reader cannot see. * - * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction: - * every one is emitted by a pass that only moves forward, and nothing here throws. + * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction. */ fun scanMarkdown(code: String): List = MarkdownScanner(code).run() @@ -36,7 +32,7 @@ private const val RULE_MARKERS = "-*_=" /** The characters that can open emphasis, strong emphasis or a strikethrough. */ private const val EMPHASIS = "*_~" -/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */ +/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */ private const val URL_STOPS = "<>\"'`|" private const val URL_TRAILING = ".,:;!?" @@ -53,8 +49,8 @@ private class MarkdownScanner(private val code: String) { val end = lineEnd(at) val open = fence if (open != null) { - // The content and the closing line alike: a fence is one block of code, and its - // own delimiters belong to it the way a string's quotes belong to the string. + // The content and the closing line alike: a fence is one block of code, and its own + // delimiters belong to it the way a string's quotes belong to the string. emit(at, end, Kind.STRING) if (closesFence(at, end, open)) fence = null } else { @@ -77,11 +73,10 @@ private class MarkdownScanner(private val code: String) { /** * One line that is not inside a fence, and whether the table it may be part of is still open. * - * A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that - * cannot be anything else. That row comes *after* the header it belongs to, so the header is - * found by looking one line ahead -- the single piece of lookahead here, and cheaper than the - * alternative of colouring every `|` in the document, which would mark the pipes in a shell - * command written in a paragraph. + * A table is recognised by its delimiter row, the only line of one that cannot be anything + * else. That row comes *after* the header it belongs to, so the header is found by looking one + * line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the + * document, which would mark the pipes in a shell command written in a paragraph. */ private fun row(start: Int, end: Int, table: Boolean): Boolean { if (tableDelimiter(start, end)) { @@ -142,10 +137,9 @@ private class MarkdownScanner(private val code: String) { } /** - * Spans, coalesced with the one before when they touch and agree. - * - * Worth doing here rather than leaving it to the caller: the line scanner emits per marker and - * per word, so a heading would otherwise arrive as a dozen abutting spans of one colour. + * Spans, coalesced with the one before when they touch and agree. Worth doing here rather than + * leaving it to the caller: the line scanner emits per marker and per word, so a heading would + * otherwise arrive as a dozen abutting spans of one colour. */ private fun emit(start: Int, end: Int, kind: Kind) { if (end <= start) return @@ -179,17 +173,16 @@ private class MarkdownScanner(private val code: String) { private fun opensFence(start: Int, end: Int): String? { val run = fenceRun(start, end) ?: return null emit(run.first, run.last + 1, Kind.STRING) - // The info word is what the fence is a fence *of*, which is metadata about the block - // rather than part of it -- the same reading as a Rust attribute above a struct. + // The info word is what the fence is a fence *of*, which is metadata about the block rather + // than part of it. emit(indented(run.last + 1, end), end, Kind.METADATA) return code.substring(run.first, run.last + 1) } /** - * Whether this line closes a fence opened by [open]. - * - * The same character, at least as many of them, and nothing else on the line -- so a longer run - * closes a shorter one and a line of backticks with a word after it does not close anything. + * Whether this line closes a fence opened by [open]: the same character, at least as many of + * them, and nothing else on the line -- so a longer run closes a shorter one and a line of + * backticks with a word after it does not close anything. */ private fun closesFence(start: Int, end: Int, open: String): Boolean { val run = fenceRun(start, end) ?: return false @@ -227,10 +220,9 @@ private class MarkdownScanner(private val code: String) { * A line made of one repeated rule character and nothing else. * * `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a - * setext heading. The two are the same line to look at and mean the same thing to a reader -- a - * rule drawn across the page -- so they get one appearance rather than a lookback to tell them - * apart. One `=` is enough because a setext underline may be a single character; a break needs - * three, which is what keeps a `- ` bullet out of here. + * setext heading. The two are the same line to look at and mean the same thing to a reader, so + * they get one appearance rather than a lookback. One `=` is enough because a setext underline + * may be a single character; a break needs three, which keeps a `- ` bullet out of here. */ private fun thematicBreak(start: Int, end: Int): Boolean { val marker = code[start] @@ -292,10 +284,9 @@ private class MarkdownScanner(private val code: String) { } /** - * `` `code` ``, closed by a run of exactly as many backticks as opened it. - * - * That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why - * the search skips over a shorter or longer run rather than stopping at the first backtick. + * `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what + * lets a span hold a backtick of its own, and why the search skips over a shorter or longer run + * rather than stopping at the first backtick. */ private fun codeSpan(start: Int, end: Int): Int { var open = start @@ -323,9 +314,8 @@ private class MarkdownScanner(private val code: String) { * `[text](destination)`, and the same with a leading `!` for an image. * * The text is drawn as prose -- it is what the reader reads -- so only the brackets around it - * are marked, and the destination is metadata: the place the link goes rather than anything - * said to the reader. A `[text]` with no destination after it is left plain, because that is - * what a reference link and a bracketed aside look like, and neither is worth guessing at. + * are marked, and the destination is metadata. A `[text]` with no destination after it is left + * plain, because that is what a reference link and a bracketed aside look like. */ private fun link(start: Int, bracket: Int, end: Int): Int { var depth = 0 @@ -357,8 +347,7 @@ private class MarkdownScanner(private val code: String) { * `` and ``, drawn as the destination they are. * * The angle brackets have to hold no whitespace and something that makes an address of it -- a - * scheme's colon or an at sign -- which is what keeps an HTML tag out: `
` has neither, and - * `` has the colon but also a space. + * scheme's colon or an at sign -- which is what keeps an HTML tag out. */ private fun autolink(start: Int, end: Int): Int { var at = start + 1 @@ -380,13 +369,12 @@ private class MarkdownScanner(private val code: String) { /** * A bare `scheme://…` written in prose, or null if one does not start here. * - * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and - * the pair of colons is what makes the match unambiguous enough to draw without a closer. + * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry. * * Where it ends is the part worth stating: the sentence's punctuation is not the address, so a * trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the - * URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too, - * because a URL in a table cell must not swallow the cell's edge. + * URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a + * table cell must not swallow the cell's edge. */ private fun url(start: Int, end: Int): Int? { if (start > 0 && isWord(code[start - 1])) return null @@ -415,14 +403,13 @@ private class MarkdownScanner(private val code: String) { } /** - * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all. + * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the + * token scanner draws a string: the quotes are part of the thing. * - * Markers and all because that is how the token scanner draws a string: the quotes are part of - * the thing. The two guards are what keep this off code that happens to be in a paragraph -- - * the opener must be followed by something to emphasise and the closer preceded by something - * emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment. - * Underscores additionally may not start or end inside a word, or every `snake_case_name` in a - * document would be half emphasised. + * The two guards keep this off code that happens to be in a paragraph: the opener must be + * followed by something to emphasise and the closer preceded by something emphasised, so `a * b + * * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start + * or end inside a word, or every `snake_case_name` would be half emphasised. */ private fun emphasis(start: Int, end: Int): Int { val marker = code[start] diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt index 5def147..117fccc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp * Claude Code marks a sentence that came from its stored memory by wrapping it in ``. Markdown has nothing to say about that, so it arrived on screen as literal * angle brackets in the middle of a sentence -- which reads as the model having emitted broken - * HTML. It is really the opposite: a claim about where something came from, which is worth showing, - * because "I was told this before" and "I worked this out just now" are different things and the - * reader cannot otherwise tell them apart. + * HTML. It is really the opposite: a claim about where something came from, and "I was told this + * before" and "I worked this out just now" are different things the reader cannot otherwise tell + * apart. * - * A tag that has not finished arriving is left alone. Streaming means the closing tag may be - * seconds away, and a half-written marker is not a marker yet. + * A tag that has not finished arriving is left alone: a half-written marker is not a marker yet. */ @Composable fun AssistantMessage( @@ -66,12 +65,10 @@ fun AssistantMessage( * A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed * prose part made while looking for them -- inspecting a message must not change it. That belongs * here rather than at the places that need the answer, because [warm] has to name the same strings - * the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports, - * and the row pays the parse in the frame it appears, which is the cost being removed. + * the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports. * * Public because [transcriptUnits] flattens settled replies into the same parts; go through - * [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per - * message. + * [ParsedReplies.partsOf] on any path that runs per fold or per page. */ fun messageParts(text: String): List { val parts = splitMemoryNotes(text) @@ -83,16 +80,14 @@ fun messageParts(text: String): List { * * Closed by default, like a tool call and a peer message and for the same reason: it is not part of * what was said to the reader, it is a note about where a claim came from. Left open it breaks the - * reply in half around a card, which reads as the answer having stopped and restarted -- and these - * arrive several to a message. + * reply in half around a card, and these arrive several to a message. * * What stays visible is which file it came from, because that is the whole of what the note claims - * and it is the part a reader scanning for "why does it think that" is looking for. + * and the part a reader scanning for "why does it think that" is looking for. * * Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to * still be open on the way back, and a card that remembered for itself would forget the moment the - * list stopped composing it. The text is a good enough name -- it does not change once the closing - * tag has arrived, so a note stays open across the moment its reply settles. + * list stopped composing it. */ @Composable fun MemoryNote( @@ -148,10 +143,8 @@ private val MEMORY_NOTE = Regex("""(.*?)""", RegexOption.DOT_MATCHES_ALL) /** - * Splits [text] into prose and memory notes, in order. - * - * Always returns at least one part, so a message with no notes in it is one piece of prose and - * costs nothing extra to draw. + * Splits [text] into prose and memory notes, in order. Always returns at least one part, so a + * message with no notes is one piece of prose and costs nothing extra to draw. */ fun splitMemoryNotes(text: String): List { val parts = mutableListOf() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt index c372d2d..bb887f1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt @@ -5,26 +5,23 @@ package com.example.aiapp * * One constant rather than a literal in each place, because the two have to agree: a picker whose * options cannot say every state its button can display is one you can leave and not get back to. - * It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request - * the session can act on rather than a name this app made up. + * It is also the Claude CLI's own word for "whatever is configured". */ const val DEFAULT_MODEL = "default" /** * A model's name as a person reads it. * - * Providers answer with their own full identifier -- Claude Code resolves `haiku` to - * `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session - * using" and far too long for a button in a row that also has to hold Stop and Send. + * Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude- + * haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using" + * and far too long for a button in a row that also holds Stop and Send. * * So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which * is the same on every model this app can show, and the release date, which distinguishes builds of - * one model rather than one model from another. What is left is the part somebody chose -- - * `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this - * does not recognise is a name it has no business editing. + * one model rather than one model from another. Anything that does not look like that is returned + * untouched. * - * A display decision, not a correction: the full name is what the session reports and what a reader - * is shown when there is room for it. + * A display decision, not a correction: the full name is what the session reports. */ fun modelLabel(model: String?): String { val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt index 234fc9a..0521f9f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt @@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { } } - // Polled rather than pushed: a download belongs to the machine, not to - // any session, so it has no event stream of its own. Slow enough not - // to matter, frequent enough that a bar moves. - // Keyed on the token as well, so the header's Refresh restarts the loop with a read now - // rather than leaving the reader watching for up to a second and a half to see whether - // anything happened. + // Polled rather than pushed: a download belongs to the machine, not to any session, so it has + // no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the + // loop with a read now rather than leaving the reader watching for a second and a half. LaunchedEffect(reloadToken) { while (true) { reload() @@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { } } } - // Inside the expanded repository's own item - // rather than as a section after the list: - // drawn after every card, a repository's files - // read as belonging to whichever card happened - // to be last. + // Inside the expanded repository's own item rather than as a section + // after the list: drawn after every card, a repository's files read as + // belonging to whichever card happened to be last. if (open) { when (val files = repoFiles) { null -> {} @@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - // A determinate bar only when the size is known. The server - // sends no total when it was never told one, and a bar drawn - // from a guess is worse than one that admits it is counting. + // A determinate bar only when the size is known. The server sends no total when it was + // never told one, and a bar drawn from a guess is worse than one that admits it is + // counting. if (download.total != null && download.total > 0) { LinearProgressIndicator( progress = { download.done.toFloat() / download.total.toFloat() }, // Blue at every value, unlike a quota bar: a download nearing its end is - // nearing success, and colouring it like a limit being approached would say - // the opposite of what is happening. + // nearing success, and colouring it like a limit being approached would say the + // opposite. color = progressColor, modifier = Modifier.fillMaxWidth(), ) @@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) { repo.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, - // The owner is the part that repeats; the model name at - // the end is what tells two entries apart. + // The owner is the part that repeats; the model name at the end is what tells + // two entries apart. overflow = TextOverflow.StartEllipsis, ) Text( @@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Disabled rather than absent, so the row reads the same whether - // this one is absent, already here, or on its way. Offering - // "Download" for a file that is downloading would be a button that - // does nothing anyone can see -- the server joins the running - // download rather than starting a second. + // Disabled rather than absent, so the row reads the same whether this one is absent, + // already here, or on its way. Offering "Download" for a file that is downloading would be + // a button that does nothing anyone can see. TextButton(enabled = !file.have && !downloading, onClick = onDownload) { Text( when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 4ba88c0..b7985bc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp * set and kept in step by hand. * * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the - * grounds that a system font may not have the glyph and whoever gets the empty box instead is never - * the person who wrote it. That objection is about *relying* on a system font, and it is exactly - * right: the answer is not to avoid glyphs but to ship them. The font here is - * `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols - * font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come - * back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the - * script did not subset is a glyph that silently isn't there. + * grounds that a system font may not have the glyph. That objection is about *relying* on a system + * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here + * is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols + * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that + * the script did not subset is a glyph that silently isn't there. * * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. - * That is what makes two icons the same size without either of them being given a size: the - * proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side - * by side came out visibly different widths, and matching them at the call site would have meant - * one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost. + * That is what makes two icons the same size without either being given a size: the proportional + * face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came + * out visibly different widths. [GLYPH_SIZE] carries the cost. * * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two - * Material Design codepoints. Those two must not drift: an icon that means "settings" in one app - * and something else in the other is the failure this is worth preventing. The script is copied - * rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to - * differ -- the point of subsetting is to ship only the codepoints one app draws. All Material - * Design bar one, so they read as one family; the exception is noted where it is declared. + * Material Design codepoints. Those two must not drift. The script is copied rather than shared + * because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the + * point of subsetting is to ship only the codepoints one app draws. */ val NerdIcons = FontFamily(Font(R.font.nerd_icons)) @@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB) * * The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what * pressing it now would do to the process, and the three marks are the three answers. An interrupt - * ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which - * is a pause, not a stop, and drawing it as a square said otherwise. + * ends a turn and nothing else, which is a pause, not a stop. */ val PAUSE_GLYPH = glyph(0xF03E4) @@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A) * `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn. * * The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than - * starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs - * while looking identical would promise something immediate and do something that waits. + * starting one, and one glyph doing both jobs would promise something immediate and do something + * that waits. */ val QUEUE_GLYPH = glyph(0xF1163) @@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A) * `fa-line_chart` -- how much of the account's rate limits is gone. * * Font Awesome's rather than Material's, which is the one break in the family above: it was asked - * for by name, and Material's chart glyphs are a bare line where this one has its axes, which is - * what makes it read as a measurement rather than as a trend. + * for by name, and Material's chart glyphs are a bare line where this one has its axes. */ val USAGE_GLYPH = glyph(0xF201) @@ -121,9 +114,8 @@ val SPEED_GLYPH = glyph(0xF04C5) * `md-folder` -- the files on the machine this session runs on. * * The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and - * the refresh arrow must not: a folder that meant something else in one of the two apps is exactly - * the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer, - * which is what makes the button say where it leads. + * the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is + * what makes the button say where it leads. */ val FOLDER_GLYPH = glyph(0xF024B) @@ -148,10 +140,9 @@ val SAVE_GLYPH = glyph(0xF0193) * The size an icon draws at beside a line of text. * * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at - * most 0.83 em of its point size and most filled a good deal less, so the number was standing in - * for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and - * keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked - * for. This is what the largest of them already drew at. + * most 0.83 em of its point size, so the number was standing in for the headroom above the tallest + * one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every + * icon in the app up by a fifth. */ private val GLYPH_SIZE = 17.sp @@ -165,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp * * The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to * the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of - * its own, and a mark cannot end up further from the button beside it than from the edge of the - * screen. That is what it was: the box was the size of the mark (28dp) and the separation was + * its own. That is what it was: the box was the size of the mark (28dp) and the separation was * bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the - * edge, so a pair that acts on one screen read as two unrelated marks with one falling off it. + * edge. * * 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has - * to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the - * mark's own corners, and beside a title it arrived at the first letter. And it is taller than any - * header's text, which is what lets the button fill a header row rather than sit in the middle of - * one -- the rows add no vertical padding of their own for the same reason they add no gap. + * to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's + * own corners and beside a title it arrived at the first letter. And it is taller than any header's + * text, which is what lets the button fill a header row rather than sit in the middle of one. */ private val GLYPH_BUTTON_SIZE = 48.dp @@ -182,11 +171,9 @@ private val GLYPH_BUTTON_SIZE = 48.dp * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside * a back arrow. * - * Two glyph buttons need nothing between them: each brings its own ring and the two add up, which - * is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for. - * Without it the pressed-state circle, which fills the whole square, arrives at the first letter of - * the title -- and the gap a reader sees between the mark and that title is then half the one - * between the two marks at the other end of the same row. + * Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text + * brings none, so the second ring has to be asked for -- without it the pressed-state circle + * arrives at the first letter of the title. */ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 @@ -195,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 * * Its own composable so that every icon button in the app is one size and one colour without each * caller saying so, and so the [label] none of them displays is still there for a screen reader -- - * which is all assistive technology has to go on, and also the answer to "what was that button for" - * six months from now. + * which is also the answer to "what was that button for" six months from now. * * [enabled] is passed through rather than left to callers hiding the button: a control that comes * and goes makes its own absence the signal, and absence cannot say whether there was nothing to do @@ -220,9 +206,8 @@ fun GlyphButton( * The same square, around a mark that is not a glyph. * * A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the - * size, spacing and touch target every other icon button on this app's headers already is -- so - * this is [GlyphButton] with the mark left to the caller rather than a second set of measurements - * beside it. The caller still owes it a [label]: nothing here draws a word. + * size, spacing and touch target every other icon button already is. The caller still owes it a + * [label]: nothing here draws a word. */ @Composable fun MarkButton( @@ -245,9 +230,7 @@ fun MarkButton( * The square a glyph button occupies, with a spinner in it instead of a mark. * * For a button whose work is under way. It takes the button's whole box rather than the mark's, so - * swapping one for the other leaves everything in the row exactly where it was -- a control that - * changed the width of its header while it worked would move its neighbours at the moment somebody - * was pressing them. + * swapping one for the other leaves everything in the row exactly where it was. */ @Composable fun GlyphSpinner(label: String, modifier: Modifier = Modifier) { @@ -273,10 +256,9 @@ fun Glyph( size: TextUnit = GLYPH_SIZE, ) { // Line height of the point size, which for this font is the square the glyph draws in: its - // ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em. - // Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a - // glyph took a seventh more vertical space than horizontal wherever one is drawn without a box - // around it -- and where there is a box, that leading is what its padding is measured through. + // ascent and descent add up to exactly one em. Left to the inherited body style the line box + // was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than + // horizontal. Text( glyph, fontFamily = NerdIcons, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt index 9970668..050b6eb 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -34,12 +34,11 @@ import org.json.JSONObject * gets a push from Google's servers, which would mean this backend talking to Google about * somebody's coding sessions, and the whole point of the tunnel is that it does not. * - * The cost Android charges for it is a notification of its own that cannot be dismissed. That is - * made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no - * sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement - * Syncthing's "hide the persistent notification" option produces. It is not hidden outright, - * because it cannot be and because it should not be: it is the honest indicator that something is - * holding a connection open. + * The cost Android charges is a notification of its own that cannot be dismissed. That is made as + * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows + * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it + * cannot be and because it should not be: it is the honest indicator that something is holding a + * connection open. */ class NotificationService : Service() { @Volatile private var stream: HttpURLConnection? = null @@ -50,18 +49,18 @@ class NotificationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val settings = loadServerSettings(this) if (settings == null) { - // Nothing to connect to. Stopping rather than idling: a service - // holding no connection still costs the ongoing notification, - // which would then be announcing work that is not happening. + // Nothing to connect to. Stopping rather than idling: a service holding no connection + // still costs the ongoing notification, which would be announcing work that is not + // happening. stopSelf() return START_NOT_STICKY } - // Through ServiceCompat so the type is stated once and ignored on - // the versions that predate types, rather than branching here. + // Through ServiceCompat so the type is stated once and ignored on the versions that predate + // types, rather than branching here. ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType()) thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) } - // Restarted if Android kills it, which is the whole point: the - // window this covers is exactly the one where nobody is watching. + // Restarted if Android kills it, which is the whole point: the window this covers is + // exactly the one where nobody is watching. return START_STICKY } @@ -73,11 +72,10 @@ class NotificationService : Service() { /** * Follows the backend's notification stream, reconnecting until stopped. * - * A dropped connection is the ordinary case here rather than an error -- a phone changes - * networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and - * forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you - * whether anything happened" on a phone in somebody's pocket is noise about a condition they - * cannot act on, and the session list already says what is waiting when they next look. + * A dropped connection is the ordinary case here rather than an error, so it retries quietly + * and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell + * you whether anything happened" is noise about a condition nobody can act on, and the session + * list already says what is waiting when they next look. */ private fun follow(settings: ServerSettings) { while (!stopping) { @@ -102,8 +100,8 @@ class NotificationService : Service() { try { connection.applyPinnedTls() connection.connectTimeout = CONNECT_TIMEOUT_MS - // No read timeout, for the reason EventStream gives: between - // notifications there is nothing to read, possibly for hours. + // No read timeout, for the reason EventStream gives: between notifications there is + // nothing to read, possibly for hours. connection.readTimeout = 0 connection.setRequestProperty("Authorization", "Bearer ${settings.token}") connection.setRequestProperty("Accept", "text/event-stream") @@ -134,28 +132,23 @@ class NotificationService : Service() { * * Keyed by session id rather than accumulating: two sessions wanting attention are two things * to know about, but one session that finished and then asked a question is one thing -- the - * question. A stack of stale rows for the same conversation is how a notification drawer - * becomes something to clear rather than read. + * question. A stack of stale rows is how a drawer becomes something to clear rather than read. */ private fun show(notification: SessionNotification) { // Nothing to tell somebody about the session they are reading. The transcript in front of - // them is already saying it, and a sound over the top of it would be this app announcing - // what the screen is showing. + // them is already saying it. if (isOnScreen(notification.sessionId)) return - // The app is up: it says this itself, as a banner over whatever screen they are on. See - // [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an - // app that already showed you each one is a drawer nobody reads. + // The app is up: it says this itself, as a banner over whatever screen they are on. Never + // both -- one thing happened, and a drawer filling up behind an app that already showed you + // each one is a drawer nobody reads. if (handOver(notification)) return val manager = NotificationManagerCompat.from(this) // Two different noes, and both are answers rather than faults: the runtime permission - // refused, and notifications switched off for the app in Android's own settings. Neither - // is reported anywhere -- the person said no, and saying it back to them through the - // channel they closed is not available anyway. + // refused, and notifications switched off for the app in Android's own settings. // // The permission only exists from Android 13. Asking an older version about it gets - // "denied" for a name it does not know, which read as the person having said no -- so - // every notification on Android 12 and below was silently dropped. Before 13 the - // switch in Android's own settings, checked below, is the whole of the answer. + // "denied" for a name it does not know, which read as the person having said no -- so every + // notification on Android 12 and below was silently dropped. val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == @@ -187,9 +180,8 @@ class NotificationService : Service() { * The type Android 14+ requires a foreground service to declare, and nothing before it. * * Named behind a version check rather than passed as a constant: the value is inlined at - * compile time and would be handed to platforms that have no concept of it, which is exactly - * the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do - * not apply. + * compile time and would be handed to platforms that have no concept of it, which is what + * lint's InlinedApi exists to catch. */ private fun foregroundType(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -226,11 +218,10 @@ class NotificationService : Service() { /** * Two channels, because they are two different things to be told. * - * The alerts are what somebody turned this on for, so they get the default importance and - * whatever sound and heads-up display the person has chosen for the app. The ongoing one is - * the platform's tax for staying connected, so it takes the lowest importance that exists. - * Both are created before the service starts, since posting to a channel that does not - * exist is silently dropped. + * The alerts are what somebody turned this on for, so they get the default importance. The + * ongoing one is the platform's tax for staying connected, so it takes the lowest + * importance that exists. Both are created before the service starts, since posting to a + * channel that does not exist is silently dropped. */ private fun createChannels(context: Context) { val manager = NotificationManagerCompat.from(context) @@ -256,12 +247,10 @@ class NotificationService : Service() { * The session somebody is looking at, or null when no screen is showing one. * * Process-wide state, which the rest of this app does without: Android constructs the - * service and the composition draws the screen, so the two have no common owner a value - * could be passed through. [showing] and [stoppedShowing] are the pair, both called from - * the one composable that shows a session. Clearing names the session rather than setting - * null outright, because moving from one session to another composes the new screen before - * the old one's coroutine is cancelled -- an unconditional clear would then throw away the - * new screen's claim and start notifying about what is on it. + * service and the composition draws the screen, so the two have no common owner. Clearing + * names the session rather than setting null outright, because moving from one session to + * another composes the new screen before the old one's coroutine is cancelled -- an + * unconditional clear would throw away the new screen's claim. */ @Volatile private var onScreen: String? = null @@ -271,10 +260,9 @@ class NotificationService : Service() { * The way a notification reaches the app instead of Android's drawer. * * Whether there is an app to reach is the subscriber count rather than a flag of its own: - * [SessionAlerts] collects this exactly while it is on screen, so there is nothing that - * could be left saying the app is up after it has gone. `tryEmit` neither suspends nor - * blocks the thread reading the stream, and the buffer is there so a handful of sessions - * finishing together all land rather than the last one winning. + * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends + * nor blocks the thread reading the stream, and the buffer is there so a handful of + * sessions finishing together all land rather than the last one winning. */ private val toApp = MutableSharedFlow(extraBufferCapacity = 8) @@ -287,9 +275,8 @@ class NotificationService : Service() { /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ fun showing(context: Context, sessionId: String) { onScreen = sessionId - // Whatever was posted about it before is about to be read, so it has nothing left - // to say -- and a row in the drawer for the conversation on screen is the same - // duplication this whole rule is about. + // Whatever was posted about it before is about to be read, so it has nothing left to + // say. NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID) } @@ -311,8 +298,8 @@ class NotificationService : Service() { * The intent that opens one session, and the id it carries back out. * * The two halves are written together so neither can be changed without the other, and the scheme - * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at - * when an intent arrives rather than two. + * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look + * at. * * The id rides in the intent's **data** rather than in an extra, which is not a style choice: * PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring @@ -345,9 +332,8 @@ data class SessionNotification( * What a notification asks of the reader, in the words they see. * * What they have to do, not what the session did: "awaitingInput" is the wire's word and says - * nothing to somebody reading a lock screen. One function because the same fact is now shown in two - * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The - * banner colours the line as well, which is its own decision and stays with the drawing. + * nothing to somebody reading a lock screen. One function because the same fact is shown in two + * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. */ fun attentionLine(kind: String): String = when (kind) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index 0da0622..f70e66b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp * * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a * transcript that puts it in their voice is making a claim about who asked for the work that - * follows -- which is exactly the question a peer message is usually the answer to. + * follows. * * Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block, - * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that - * bought; what matters here is that the pieces have to add up to the card that was there before, so - * the fill, the corner radius and the padding all live in [peerSurface] rather than being written - * out at each piece. + * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought; + * what matters here is that the pieces have to add up to the card that was there before, so the + * fill, the corner radius and the padding all live in [peerSurface]. */ @Composable fun PeerHeadRow( @@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. // Without this the card closes everywhere except on the text, which is most of it. CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { - // The gap the card's own column used to provide between its heading and its prose, - // and between one block and the next -- inside the piece, so the card's fill runs - // through it. + // The gap the card's own column used to provide between its heading and its prose, and + // between one block and the next -- inside the piece, so the card's fill runs through + // it. MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) } } @@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl /** * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. * - * A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which - * is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a - * card can be cut up at all. Each piece paints the caller's container colour the way a - * [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message, - * so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an - * opened peer message and a long user message -- because two copies of the corner logic is how one - * of them grows a seam. + * A filled Material card is elevation zero, so there is no shadow that a seam would show through -- + * which is the whole reason a card can be cut up at all. Each piece paints the caller's container + * colour and rounds only the corners at the ends of the message, so the pieces abut into one + * continuous card. Shared by the two rows cut this way -- an opened peer message and a long user + * message -- because two copies of the corner logic is how one of them grows a seam. * - * The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps - * the top of it, the bottom piece the bottom, and the middle pieces neither. + * The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top + * of it, the bottom piece the bottom, and the middle pieces neither. */ @Composable fun Modifier.cardPiece( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt index 6665132..3a0c5e2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt @@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp * What is about to be sent, directly above the box it will be sent from. * * The count on the "+" button was the whole of what said an image was attached, so the only way to - * find out *which* image was to send it. A control belongs with the thing it acts on, and what - * these are attached to is the message being typed -- which is why they sit here rather than - * anywhere else on the screen. + * find out *which* image was to send it. A control belongs with the thing it acts on. * * Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is - * in it, so four attachments look like four of the same thing rather than four smaller ones. A file - * is a tile of the same height carrying its name, since a name is all there is to show of it. + * in it, so four attachments look like four of the same thing rather than four smaller ones. */ @Composable fun PendingAttachments( @@ -67,8 +64,7 @@ fun PendingAttachments( * * Removal is here because there is nowhere else it could be: an image picked by mistake could * otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a - * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and - * the label is what says so, since nothing about the picture does. + * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip. */ @Composable private fun PendingThumbnail( @@ -84,8 +80,7 @@ private fun PendingThumbnail( .clip(shape) // An outline as well as a fill. Most of what gets attached here is a screenshot of a // dark app, and cropped to a square its middle is often near-black -- against this - // background the tile then had no edge at all, and the only thing saying an image was - // attached was the cross drawn on top of nothing. + // background the tile then had no edge at all. .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) // Behind the picture as well as under a missing one, so the tile is a tile before // anything has arrived to fill it. @@ -105,9 +100,9 @@ private fun PendingThumbnail( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - // A spinner, as the transcript's images have: one appearance for "a picture - // is on its way", learned once. An ellipsis had to be read as a spinner that - // was not moving. + // A spinner, as the transcript's images have: one appearance for "a picture is + // on its way", learned once. An ellipsis had to be read as a spinner not + // moving. CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } else -> @@ -118,14 +113,12 @@ private fun PendingThumbnail( modifier = Modifier.size(THUMBNAIL), ) } - // The whole square removes it, and this only says so. A cross small enough to sit in - // the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target - // would be a control drawn at a size nobody can hit. + // The whole square removes it, and this only says so. A cross small enough to sit in the + // corner of a 64dp thumbnail is smaller than a fingertip. // // The disc is sized here and the mark centred inside it, rather than the glyph being - // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box - // to the corner hung the visible mark over the edge and put its backing somewhere the - // eye reads as a second, misplaced square. + // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to + // the corner hung the visible mark over the edge. Box( Modifier.align(Alignment.TopEnd) .padding(2.dp) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index cce7d2d..be44e7e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -3,15 +3,13 @@ package com.example.aiapp import com.example.wgapplink.PinnedTls import java.net.HttpURLConnection -// PINNED_CA_PEM is generated at build time from the CA on the machine doing -// the build -- see the generatePinnedCert task in build.gradle.kts. It is -// deliberately not a checked-in constant: the private key that signs against -// it must never be anywhere this repo is, and an APK should pin whatever CA -// the backend it was built for actually serves. +// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the +// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the +// private key that signs against it must never be anywhere this repo is, and an APK should pin +// whatever CA the backend it was built for actually serves. // -// The pinning itself lives in wg-app-link, since dev-updater needs exactly -// the same thing. What stays here is the one product-specific fact -- which -// certificate this app pins. +// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What +// stays here is which certificate this app pins. private val pinned = PinnedTls(PINNED_CA_PEM) /** Every request this app makes goes through this -- there is no unpinned path. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt index 201847d..2b3df2c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt @@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp * * A composable rather than a modifier repeated at each site, because the inset is part of it -- * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three - * copies of "clip, fill, pad" drift apart the first time one of them is adjusted. + * copies of "clip, fill, pad" drift apart the first time one is adjusted. * - * The colour is [rawSurface], which is also what a code block inside a reply is given; that is the - * point of having one name for it. Markdown's blocks are painted by the renderer rather than by - * this, since it draws its own, but they are the same colour on purpose. + * The colour is [rawSurface], which is also what a code block inside a reply is given. */ @Composable fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { Column( modifier .fillMaxWidth() - // Smaller than a card's radius, and deliberately: this sits *inside* one, and a - // rounded rectangle drawn at the same radius as the rounded rectangle behind it reads - // as a misprint rather than as nesting. + // Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded + // rectangle drawn at the same radius as the one behind it reads as a misprint. .clip(MaterialTheme.shapes.extraSmall) .background(rawSurface) .padding(horizontal = 8.dp, vertical = 6.dp), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index 1db6b3a..4316b71 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -4,17 +4,16 @@ import java.time.Duration import java.time.OffsetDateTime // How long is left in a usage window. Shared by the session bar and the usage screen: the -// arithmetic is the same in both and only the sentence around it differs, so everything here -// returns the span or the state on its own and leaves the wording to the caller. +// arithmetic is the same in both, so everything here returns the span or the state on its own and +// leaves the wording to the caller. /** * "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. * * Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s * left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the - * figure on a minute it has already spent -- so the reader watching the number decide whether to - * start something was consistently told less headroom than they had. One rule, so the session bar - * and the usage dialog cannot round a shared measurement two different ways. + * figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot + * round a shared measurement two different ways. */ fun formatSpan(until: Duration): String { val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1) @@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String { * Three answers rather than a nullable duration, because two of them shared `null` and they are not * the same thing at all. A window the server sent no reset time for is one that is **not running**: * the five-hour window is anchored to the block it started in, so between sessions there is nothing - * counting down and the API says so by omitting the field -- measured against a live response on - * 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work - * resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it - * is the only one worth those words. + * counting down and the API says so by omitting the field. A timestamp that did arrive and could + * not be read is the genuinely unknown case. * * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on * the one row somebody reads before starting something big -- and the usage dialog, looking at the - * same field, quietly drew nothing. Two rules for one missing value; this is the rule. + * same field, quietly drew nothing. */ sealed class WindowEnd { /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt index 7587858..140b009 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt @@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll" * * Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by * the row key the list draws with. An index means nothing across a reopen, since the transcript is - * fetched newest-first and a session that has said anything since has renumbered every position. - * The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run - * the name of its newest half, and the newest half is whatever the newest page happened to start - * with -- so an active session renames its tool runs every time it is reopened, and an anchor - * naming one is never found. A seq is the server's own numbering, assigned once and never moved. + * fetched newest-first. The row key looks stable and is not: a tool row is named after its run, + * `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest + * page started with -- so an active session renames its tool runs every time it is reopened. A seq + * is the server's own numbering, assigned once and never moved. * - * [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and - * [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone - * is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway - * down it is put back at that block, not at the reply. + * [unit] is which unit of the row the viewport started at and [offset] how far that unit was + * scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can + * be forty blocks long. */ data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0) /** * On this device rather than on the backend, which is where this app otherwise keeps state so every * device sees it. Scroll position is the same exception a draft is: it is where the phone in - * somebody's hand is pointed, and having one device jump because another was scrolled would be a - * surprise rather than a convenience. + * somebody's hand is pointed. */ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { val stored = @@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { val fields = stored.split(':') val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null - // Positions saved before the unit was recorded name the row's oldest unit, which is the - // closest older place -- the same choice [unitIndexFor] makes when a unit is gone. + // Positions saved before the unit was recorded name the row's oldest unit, which is the closest + // older place -- the same choice [unitIndexFor] makes when a unit is gone. return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0) } @@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { * Records where [sessionId] is being read, or forgets it when [anchor] is null. * * The path out is reading to the newest end, which is what the caller passes null for: a session - * left at the bottom has nothing to restore and should open at the bottom, which is also the cheap - * case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at - * the cost `Drafts.kt` describes. + * left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its + * key behind, for the reason and at the cost `Drafts.kt` describes. */ fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt index ddb30a3..9947569 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings /** * This app's enrollment, which is the whole of what is product-specific about it. * - * Both values are load-bearing and neither may be changed casually. The scheme is what routes a - * scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the - * token is already sealed under on every enrolled phone -- changing it would leave those phones - * reading as not enrolled, with no error to explain why. + * Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev + * Updater, and the key alias names the Android Keystore key the token is already sealed under on + * every enrolled phone -- changing it would leave those phones reading as not enrolled. */ private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt index 0843418..611bf79 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt @@ -33,16 +33,14 @@ import androidx.lifecycle.repeatOnLifecycle /** * A session wanting attention, said over the app rather than through Android's drawer. * - * Two places can carry the same fact and only one of them is right at a time. A row in the shade is - * for somebody looking at something else: it makes a sound, it waits however long it has to, and - * acting on it means leaving whatever they were doing. Somebody with this app open needs none of - * that -- they are already here, and what a tap on the notification would have done is what a tap - * on this does. So while these are on screen the stream is delivered here instead, which is - * arranged by the collection below and nothing else; see `NotificationService.forTheScreen`. + * Two places can carry the same fact and only one is right at a time. A row in the shade is for + * somebody looking at something else: it makes a sound, it waits however long it has to, and acting + * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So + * while these are on screen the stream is delivered here instead, which is arranged by the + * collection below and nothing else. * - * A banner can go three ways, and each is somebody deciding something different: tapped, which - * opens the session; pushed off either side; or left alone, in which case it goes by itself when - * the bar across its foot runs out. + * A banner can go three ways, each somebody deciding something different: tapped, which opens the + * session; pushed off either side; or left alone, in which case it goes when the bar runs out. */ @Composable fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) { @@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod arrivals++ val alert = SessionAlert(notification, arrivals) // One banner per session, replacing that session's own -- the same rule the - // drawer follows, and for the same reason: a session that finished and then - // asked a question is one thing to know about, the question. It keeps its - // place in the queue rather than moving to the end, because the reader may - // already be reaching for it. + // drawer follows: a session that finished and then asked a question is one + // thing to know about, the question. It keeps its place in the queue rather + // than moving to the end, because the reader may already be reaching for it. val already = queue.indexOfFirst { it.notification.sessionId == notification.sessionId } if (already >= 0) queue[already] = alert else queue.add(alert) } } finally { - // Leaving the app hands the job back to the drawer, so nothing arriving while it - // is away is lost. What would be lost is the truth of what is already up: these - // say a session wants somebody *now*, and one still sitting here on a return - // several minutes later is a claim nobody checked. Frozen, too -- Compose stops - // the clock with the window, so the timer that was going to retire it has been - // standing still the whole time. + // Leaving the app hands the job back to the drawer, so nothing arriving while it is + // away is lost. What would be lost is the truth of what is already up: these say a + // session wants somebody *now*, and one still sitting here on a return several + // minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock + // with the window. queue.clear() } } } - // Oldest at the top, so a new one appears below the ones already being read instead of - // shoving them down the screen mid-reach. + // Oldest at the top, so a new one appears below the ones already being read instead of shoving + // them down the screen mid-reach. Column(modifier.fillMaxWidth().padding(8.dp)) { queue.forEach { alert -> key(alert.arrival) { @@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv * One banner: what wants attention, and how long this has left to say so. * * The bar and the going away are one value rather than a bar beside a timer, because two of them - * would be two accounts of the same countdown and only one can be the one that fires. What is drawn - * is therefore the thing that decides, which is the only arrangement where a bar that has emptied - * cannot be sitting under a banner that is still there. + * would be two accounts of the same countdown and only one can be the one that fires. */ @Composable private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) { @@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U ), // Outlined, because the step it needs to make is not one this palette can make with a // tint: the card under a banner on the session list is the same surface, so a banner - // relying on colour alone reads as one more row that happens to be in the way. The - // border is the one cue, and the elevation beside it is the platform's shadow rather - // than a second tint -- Material draws no tonal overlay over a container stated here. + // relying on colour alone reads as one more row in the way. The border is the one cue. border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), ) { @@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U Text( alert.notification.title, style = MaterialTheme.typography.titleSmall, - // One line, cut at the tail: a session is identified by the start of its - // name, and a banner that grew with the name would move the one below it. + // One line, cut at the tail: a session is identified by the start of its name, + // and a banner that grew with the name would move the one below it. maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U LinearProgressIndicator( progress = { life.value }, // Blue because it is reporting how much of something is left rather than passing - // judgement on it -- the reason `progressColor` exists. Stated beside the track, - // which is the card's own colour so that the spent part reads as empty rather - // than as a second bar. + // judgement on it. Stated beside the track, which is the card's own colour so that + // the spent part reads as empty rather than as a second bar. color = progressColor, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, drawStopIndicator = {}, @@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U * How long a banner stays if nobody touches it. * * Long enough to read a session name and a line, short enough that a stack of them clears itself - * while somebody is still on the screen that produced them. The bar makes the number visible, so - * this is a duration the reader can watch rather than one they have to learn. + * while somebody is still on the screen that produced them. The bar makes the number visible. */ private const val ALERT_LIFE_MS = 6_000 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt index df21ca6..e440b1c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -49,10 +49,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean) /** * Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling - * does not refetch. - * - * Shared by the transcript's images and the composer's pending attachments, because the fetch, the - * decode and the two-state answer are one block of logic that had been written twice. + * does not refetch. Shared by the transcript's images and the composer's pending attachments, + * because the fetch, the decode and the two-state answer are one block of logic that had been + * written twice. */ @Composable fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap { @@ -75,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri * An image in the transcript: a fixed-height thumbnail that opens full screen. * * The height is decided before the bytes arrive and never changes. An image row that grew when it - * finished loading pushed everything below it, so a transcript being read scrolled itself while - * somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport - * moved the text under the reader's eyes. Reserving the final height makes loading invisible, which - * is what it should be. + * finished loading pushed everything below it, so a transcript being read scrolled itself -- and in + * a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes. * * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather - * than as a page of its own. Full size is one tap away -- but the full-size view itself is not - * here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list; - * see that function for the reason. + * than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the + * screen, which draws [SessionImageViewer] outside the list. */ @Composable fun SessionImage( @@ -97,9 +93,8 @@ fun SessionImage( val heightPx = with(LocalDensity.current) { height.roundToPx() } Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { when (val image = bitmap) { - // Two states, not one: an image still arriving and an image that will never arrive - // look nothing alike to a reader who can do something about the second. So one gets a - // spinner in the space the picture is about to fill, and the other gets words. + // Two states, not one: an image still arriving and an image that will never arrive look + // nothing alike to a reader who can do something about the second. null -> if (failed) { Text( @@ -130,15 +125,12 @@ fun SessionImage( * `Read` on its own is a row of one call, and the moment the next call arrives the two become a * group -- a different composable in a different part of the tree, so everything the old subtree * remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the - * transcript because the session made another tool call. The same happens to a row regrouped by a - * page of history landing. + * transcript because the session made another tool call. * - * Held by the screen, none of that reaches it: what is open is a property of the screen, not of - * whichever row happened to draw the thumbnail. + * Held by the screen, none of that reaches it: what is open is a property of the screen. * * The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go - * through. Paid deliberately rather than plumbed around: it is one request for a picture somebody - * asked to see, and the loading and unavailable states below are the same two the thumbnail draws. + * through. Paid deliberately: it is one request for a picture somebody asked to see. */ @Composable fun SessionImageViewer( @@ -157,9 +149,9 @@ fun SessionImageViewer( contentAlignment = Alignment.Center, ) { when (val image = bitmap) { - // Two states, not one, exactly as the thumbnail has them: still coming, and never - // coming. Stated in white because this box paints its own black behind them and a - // theme colour would be picked against a surface that is not there. + // Two states, not one, exactly as the thumbnail has them. Stated in white because + // this box paints its own black behind them and a theme colour would be picked + // against a surface that is not there. null -> if (failed) { Text( @@ -170,8 +162,7 @@ fun SessionImageViewer( } else { // The whole dialog is the area this picture is about to fill, so the // spinner sits in the middle of it. White for the same reason the words - // beside it are: this box paints its own black, and a theme colour would - // be chosen against a surface that is not there. + // beside it are. CircularProgressIndicator(color = Color.White) } else -> ZoomableImage(image) @@ -185,11 +176,10 @@ fun SessionImageViewer( * * A square of the row's own height rather than the full width of the transcript: the height is what * [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width - * placeholder would promise a picture wider than most of them turn out to be. Square is the closest - * thing to "the size of it" that can be drawn before knowing. + * placeholder would promise a picture wider than most turn out to be. * - * Tinted, so the reader can see that something is being kept for a picture. That is also what - * distinguishes it from the failure beside it, which is words on the ordinary surface. + * Tinted, so the reader can see that something is being kept for a picture -- which is also what + * distinguishes it from the failure beside it, words on the ordinary surface. */ @Composable private fun LoadingImage(height: Dp) { @@ -210,8 +200,8 @@ private val LOADING_SPINNER = 24.dp * Four lines of the body style the transcript is set in. * * Measured from the type rather than written as a dp, so it stays four lines when the text size - * changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded - * height would be wrong. + * changes -- including when the reader has scaled fonts up, which is when a hardcoded height is + * wrong. */ @Composable private fun thumbnailHeight(): Dp { @@ -226,8 +216,7 @@ private fun thumbnailHeight(): Dp { * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. * * A small image blown up with interpolation turns into a blur that hides what it is -- the same - * image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision - * per image rather than a preference set once. + * image with hard pixel edges stays readable. Shrinking wants the opposite. */ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High @@ -237,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality * * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image - * visible, which is the thing a reader wants first; zoom is theirs from there. + * visible. */ @Composable private fun ZoomableImage(image: ImageBitmap) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 5f63f55..1c3cc2b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -53,24 +53,19 @@ fun SessionListScreen( var listState by remember { mutableStateOf>>(LoadState.Loading) } var confirmingDelete by remember { mutableStateOf(null) } - // Failures that belong to one session rather than to the list, keyed by - // its id and shown on its own card. The two scopes are decided by - // whether the server answered: it answered and refused, so this says - // nothing about the other rows, where a server that has stopped - // answering leaves every row stale and is `listState`'s to report. + // Failures that belong to one session rather than to the list, keyed by its id and shown on its + // own card. The two scopes are decided by whether the server answered: it answered and refused, + // so this says nothing about the other rows. // - // Cleared on the next successful load below -- an entry outlives its - // session otherwise, and would reappear against whatever the phone - // fetched next. + // Cleared on the next successful load below -- an entry outlives its session otherwise. var deleteErrors by remember { mutableStateOf>(emptyMap()) } - // Which sessions have a delete in flight. A set of ids rather than a flag on the row, - // because the rows are rebuilt from whatever the server last said and this belongs to the - // request rather than to the session. + // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because + // the rows are rebuilt from whatever the server last said and this belongs to the request. var deleting by remember { mutableStateOf>(emptySet()) } - // This phone's copies of these sessions' transcripts, pruned from here because this is where - // a session stops existing. See TranscriptCache. + // This phone's copies of these sessions' transcripts, pruned from here because this is where a + // session stops existing. See TranscriptCache. val context = LocalContext.current val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } @@ -83,10 +78,9 @@ fun SessionListScreen( withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } deleteErrors = emptyMap() // The path out for a cached transcript whose session was deleted somewhere - // else -- from another device, or at the backend. This list is the only place - // that ever learns the full set, and what the residue costs here is megabytes - // rather than a draft's few bytes. On the answer rather than in `finally`: a - // list that failed to arrive says nothing about which sessions exist. + // else. This list is the only place that ever learns the full set. On the + // answer rather than in `finally`: a list that failed to arrive says nothing + // about which sessions exist. withContext(Dispatchers.IO) { transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) } @@ -103,12 +97,9 @@ fun SessionListScreen( Column(Modifier.fillMaxSize().padding(16.dp)) { when (val state = listState) { is LoadState.Loading -> CircularProgressIndicator() - // The message as Api.kt wrote it, with nothing added: it is - // already a whole sentence naming the address and what to - // check, so a prefix here read "Couldn't reach the server: - // Couldn't reach the server at ...". It was also a guess -- - // a delete that the server itself refused had reached it - // fine. + // The message as Api.kt wrote it, with nothing added: it is already a whole + // sentence naming the address and what to check, so a prefix here read "Couldn't + // reach the server: Couldn't reach the server at ...". is LoadState.Error -> Text( state.message, @@ -122,8 +113,7 @@ fun SessionListScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Awaiting-answer first (the point of the screen), then - // most recently active. + // Awaiting-answer first (the point of the screen), then most recently active. val ordered = state.value.sortedWith( compareByDescending { it.status == "awaitingInput" } @@ -155,40 +145,37 @@ fun SessionListScreen( confirmingDelete?.let { session -> // Reset per session, so a toggle turned on for one conversation is not still on for the - // next one somebody opens this dialog for. Off to begin with: see [deleteSession]. + // next. Off to begin with: see [deleteSession]. var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } AlertDialog( onDismissRequest = { confirmingDelete = null }, title = { Text("Delete \"${session.title}\"?") }, text = { // Two different acts behind one button, so it says which one this is. What - // separates them is whether the *driver* keeps its own record of the - // conversation -- the Claude Code CLI does, under ~/.claude/projects, whether - // this app spawned the session or imported it; echo and llama.cpp do not, and - // for those the app's transcript is the only copy there is. + // separates them is whether the *driver* keeps its own record of the conversation + // -- the Claude Code CLI does, whether this app spawned the session or imported it; + // echo and llama.cpp do not. // // This used to branch on `imported`, above a comment asserting that "a session - // started here has no copy anywhere". That was simply false for every - // claude-cli session this app spawned, and the two warnings disagreed about - // sessions that were equally recoverable. Getting it wrong in that direction - // is the expensive one: "this can't be undone", said of something that can, - // spends the credibility the sentence needs on the sessions where it is true. + // started here has no copy anywhere". That was false for every claude-cli session + // this app spawned, and getting it wrong in that direction is the expensive one: + // "this can't be undone", said of something that can, spends the credibility the + // sentence needs. // - // Neither branch promises a restore. The recoverable one says what is known -- - // the driver keeps its own record -- rather than that the file is still there, - // which nothing here checked; and it names what goes either way, because this - // app's transcript holds images, peer messages and commands that the CLI's own - // record never had. + // Neither branch promises a restore. The recoverable one says what is known -- the + // driver keeps its own record -- rather than that the file is still there, and it + // names what goes either way, because this app's transcript holds images, peer + // messages and commands the CLI's own record never had. Column { Text( when { !session.keepsOwnTranscript -> "Kills the process and deletes the conversation. Nothing else " + "keeps a copy, so this can't be undone." - // The sentence below is the one the toggle makes false, which is why - // it is written twice rather than appended to: leaving "should still - // be there to import again" on screen beside a switch that removes it - // is the reassurance being read at the moment it stops being true. + // The sentence below is the one the toggle makes false, which is why it + // is written twice rather than appended to: leaving "should still be + // there to import again" on screen beside a switch that removes it is + // the reassurance being read at the moment it stops being true. alsoDeleteForeign -> "Kills the process and deletes both copies of the conversation: " + "this app's, and Claude Code's own transcript on the " + @@ -203,12 +190,12 @@ fun SessionListScreen( ) // Only where there is a second copy to decide about. Absent rather than // disabled, because this is not a capability being withheld: for echo and - // llama.cpp there is no other transcript, and a switch offering to delete - // one would be asking about something that does not exist. + // llama.cpp there is no other transcript, and a switch offering to delete one + // would be asking about something that does not exist. if (session.keepsOwnTranscript) { Spacer(Modifier.height(16.dp)) - // Its own row rather than beside the paragraph: a switch is taller than - // a line of text and re-centres whatever shares a row with it. + // Its own row rather than beside the paragraph: a switch is taller than a + // line of text and re-centres whatever shares a row with it. Row(verticalAlignment = Alignment.CenterVertically) { Text( "Delete Claude Code's transcript too", @@ -229,8 +216,7 @@ fun SessionListScreen( onClick = { confirmingDelete = null // Marked here rather than after the request returns: the row has to say - // something is happening to it from the moment it is asked for, which - // is the whole of what this state is for. + // something is happening to it from the moment it is asked for. deleting = deleting + session.id deleteErrors = deleteErrors - session.id scope.launch { @@ -241,10 +227,9 @@ fun SessionListScreen( // session exactly as it was, and its transcript with it. transcriptCache.session(session.id).purge() } - // Only this row, and only what changed. Refetching the list - // instead put every other session back through loading and - // handed the reader an empty screen -- to report on something - // that was never in doubt. + // Only this row, and only what changed. Refetching the list instead + // put every other session back through loading and handed the + // reader an empty screen, to report on something never in doubt. val loaded = listState if (loaded is LoadState.Loaded) { listState = @@ -263,8 +248,8 @@ fun SessionListScreen( } } ) { - // Coloured by consequence: this takes something away, and does so wherever - // it appears -- the same rule the import screen's Delete follows. + // Coloured by consequence: this takes something away, and does so wherever it + // appears -- the same rule the import screen's Delete follows. Text("Delete", color = MaterialTheme.colorScheme.error) } }, @@ -286,8 +271,7 @@ private fun SessionCard( * * Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its * way out without claiming it has gone: a row removed the moment Delete is pressed is a promise - * about a request that has not been answered yet, and putting it back when the server refuses - * is worse than never having taken it away. + * about a request that has not been answered yet. */ deleting: Boolean, onOpen: () -> Unit, @@ -295,9 +279,9 @@ private fun SessionCard( ) { BusyItem(label = if (deleting) "deleting" else null) { Card( - // Off while the delete is in flight: a card that still opens a session it is - // deleting is a race the reader can start by tapping. On the card rather than in - // [BusyItem], which leaves gestures alone so the list still scrolls. + // Off while the delete is in flight: a card that still opens a session it is deleting + // is a race the reader can start by tapping. On the card rather than in [BusyItem], + // which leaves gestures alone so the list still scrolls. Modifier.fillMaxWidth() .combinedClickable( enabled = !deleting, @@ -320,9 +304,9 @@ private fun SessionCard( Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( - // Machine, then what runs on it, then what it is set to: the same order - // and separator as the session screen's header and the usage dialog, so - // one pair of facts is not written three ways. + // Machine, then what runs on it, then what it is set to: the same order and + // separator as the session screen's header and the usage dialog, so one + // pair of facts is not written three ways. listOfNotNull( session.setupName, session.provider, @@ -341,8 +325,7 @@ private fun SessionCard( } error?.let { Spacer(Modifier.height(8.dp)) - // The server's own words, unprefixed, the way every other - // failure in this app is shown. + // The server's own words, unprefixed, the way every other failure is shown. Text( it, style = MaterialTheme.typography.bodySmall, @@ -362,16 +345,16 @@ fun StatusText(status: String) { "running" -> "running" to runningColor "compacting" -> "compacting" to commandColor "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant - // Said in words, because it differs in kind from the others rather than in degree: - // the session is not idle and has not exited, nobody has been able to find out - // which. A muted colour alone would read as one of the quiet states. + // Said in words, because it differs in kind from the others rather than in degree: the + // session is not idle and has not exited, nobody has been able to find out which. A + // muted colour alone would read as one of the quiet states. "unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant else -> status to MaterialTheme.colorScheme.onSurfaceVariant } Row(verticalAlignment = Alignment.CenterVertically) { if (sessionWorking(status)) { - // The same colour as the word beside it: the two are one signal, and a spinner in - // the theme's accent says the state is something other than what the label says. + // The same colour as the word beside it: the two are one signal, and a spinner in the + // theme's accent says the state is something other than what the label says. CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, 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 40086f5..12f2aff 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -90,11 +90,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * How big the "still loading this conversation" spinner is. - * - * Bigger than the ones inside a tool card, which are 16dp and report on one call among many, and - * smaller than a splash: this one is standing in for the whole screen while there is nothing else - * on it, and it is the only thing to look at. + * How big the "still loading this conversation" spinner is: bigger than the ones inside a tool + * card, which report on one call among many, and smaller than a splash. */ private val LOADING_SPINNER = 48.dp @@ -102,19 +99,13 @@ private val LOADING_SPINNER = 48.dp * How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history * before the next page is fetched. * - * Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance the - * question is actually about: how far the reader can keep going before they run out. A row is - * anything from one line to a page, so a count of rows is that distance only by accident. Eight - * rows was the number once, and on a tool-heavy transcript eight rows is less than one screen: the - * reader reached the end of what was loaded on *every* swipe and waited a round trip standing - * there, which is a list running out of transcript rather than a slow frame. + * Multiplied by the viewport to give a number of *pixels*, which is the distance the question is + * actually about. A row is anything from one line to a page, so a count of rows is that distance + * only by accident: eight rows was the number once, and on a tool-heavy transcript that is less + * than one screen, so the reader ran out of loaded transcript on every swipe. * - * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched - * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over - * the tunnel -- and the distance a hard fling covers while that fetch is in flight is several - * screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger - * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever - * happened to be on screen. + * Six, because the two ways to be wrong are not the same size: firing early costs a page nobody + * reads, firing late is a spinner under somebody's finger for a whole round trip over the tunnel. */ private const val HISTORY_SCREENS = 6 @@ -122,34 +113,26 @@ private const val HISTORY_SCREENS = 6 * How many *rows* a backwards page asks for. * * Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on - * one real transcript (2,426 events, 2026-08-30) the whole conversation was seven assistant - * messages, the median folding four hundred deltas into one row. A page counted in events was a - * fifth of a single row, so reaching a screenful took dozens of sequential round trips and the - * reader stood at the boundary through every one. The server now joins each delta run into the one - * event the fold makes of it (`fetchTranscript(coalesce = true)`), so a page of rows is a page of - * the screen whatever the delta density. + * one real transcript (2,426 events) the whole conversation was seven assistant messages, the + * median folding four hundred deltas into one row. A page counted in events was a fifth of a single + * row, so reaching a screenful took dozens of sequential round trips. * - * A few screens' worth, so one page clears the cushion below and the reader reaches loaded content - * without a fetch in the way. A page that still falls short is followed by another in the - * background, nobody waiting on either. + * A few screens' worth, so one page clears the cushion below. A page that still falls short is + * followed by another in the background, nobody waiting on either. * - * The opening page stays counted in events and small ([fetchTranscript]'s default): it is on the - * critical path of showing the screen, only has to fill a viewport, and is the newest window, where - * coalescing is unsafe for the live cursor anyway. + * The opening page stays counted in events and small: it is on the critical path of showing the + * screen, and it is the newest window, where coalescing is unsafe for the live cursor anyway. */ private const val HISTORY_PAGE = 40 /** * The most events one request of a restore may ask for. * - * A restore knows exactly how far back it has to reach, so it asks for that in one request rather - * than walking there a page at a time. This bounds the request anyway, because "exactly how far" is - * however far the reader had scrolled and there is no bound on that -- and a single response of - * arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an - * event, measured on a real transcript, this is about three megabytes. - * - * Going past it costs another request rather than anything being missed, so the number only trades - * round trips against response size. + * A restore knows exactly how far back it has to reach, so it asks in one request rather than + * walking there a page at a time. This bounds it anyway, because "exactly how far" is however far + * the reader had scrolled -- and a single response of arbitrary size is the shape a phone on a slow + * tunnel handles worst. At roughly 800 bytes an event, this is about three megabytes. Going past it + * costs another request rather than anything being missed. */ private const val RESTORE_PAGE_MAX = 4000 @@ -157,21 +140,18 @@ private const val RESTORE_PAGE_MAX = 4000 * Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one. * * The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind - * it arrives, and it grows -- so a restore that stopped exactly at the anchor would put the reader - * a screen out once that growth landed. This is in events, like the rest of the restore span: that - * path counts events to reach a known seq and does not coalesce. + * it arrives, and it grows -- so a restore stopping exactly at the anchor would put the reader a + * screen out once that growth landed. */ private const val RESTORE_PAGE_CUSHION = 400 /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * - * Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are - * written from the layout phase; a snapshot write there that composition reads would schedule - * another recomposition, and the correction has to land inside the frame that is already being laid - * out rather than in a later one. Nothing observes these, so nothing needs to. - * - * [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one. + * Deliberately *not* snapshot state, which is the point of the class. Both fields are written from + * the layout phase; a snapshot write there that composition reads would schedule another + * recomposition, and the correction has to land inside the frame already being laid out. [key] is + * cleared by the resize it was set for, so it cannot be spent on an unrelated one. */ private class TopEdgeHold { var key: Any? = null @@ -183,13 +163,12 @@ private class LastHeight { } /** - * Which row the last touch landed in, and whether it landed in the row's top half -- which is the - * end that row should hold when it changes height; see [holdTopEdge]. + * Which row the last touch landed in, and whether it landed in the row's top half -- the end that + * row should hold when it changes height; see [holdTopEdge]. * - * One slot rather than a map, because only the touch that is about to toggle something matters: + * One slot rather than a map, because only the touch about to toggle something matters: * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each - * *visible* row -- the lazy list is what makes that affordable, since only rows on screen have one - * and it runs on touch, not per frame. Not snapshot state: nothing composes from it. + * *visible* row, so it costs only rows on screen and runs on touch rather than per frame. */ private class LastTouch { var key: Any? = null @@ -200,14 +179,12 @@ private class LastTouch { * Keeps this row's top edge where it is when the row changes height, if it was asked to. * * This runs in the *layout* phase, from the measurement that discovers the new height, and that is - * the whole reason it is a modifier rather than an effect. A correction posted to a coroutine - * arrives a frame or more after the layout it is correcting, so the wrong position is drawn once - * before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling - * from here happens before anything is drawn, so there is no frame to see and nothing that depends - * on how quickly the correction is scheduled. + * why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or + * more after the layout it is correcting, so the wrong position is drawn once first -- visible as a + * flick, and worse the faster the screen refreshes. * * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of - * exactly that much is what leaves the top edge where it was. + * exactly that much leaves the top edge where it was. */ @Composable private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { @@ -215,8 +192,8 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni return onSizeChanged { size -> val previous = last.value last.value = size.height - // A first measurement has no previous height to have moved from, and a row that came - // back after being scrolled away is a first measurement again. + // A first measurement has no previous height to have moved from, and a row that came back + // after being scrolled away is a first measurement again. if (previous == null || previous == size.height || held.key != key) return@onSizeChanged held.key = null hold(size.height - previous) @@ -224,11 +201,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni } // The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in -// TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed -// for unrelated reasons while they shared this file. - -// isImeVisible: see the comment beside `imeVisible` below for why the keyboard's own -// self-correction needs it. +// TranscriptItems.kt: it is pure event folding with no screen in it. +// +// isImeVisible: see the comment beside `imeVisible` below. @OptIn(ExperimentalLayoutApi::class) @Composable fun SessionScreen( @@ -247,87 +222,72 @@ fun SessionScreen( val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } - // Seeded from the row this screen was opened from, so a conversation already under way says - // how much it is holding before any turn happens here. Null is "nobody has measured it", - // which is a different answer from an empty context and is drawn differently. + // Seeded from the row this screen was opened from, so a conversation already under way says how + // much it is holding before any turn happens here. Null is "nobody has measured it", which is a + // different answer from an empty context and is drawn differently. var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } - // When the current compaction started and how long ago that is. The moment comes off the - // `compacting` status event itself -- the server timestamps every transcript line -- rather - // than off this device noticing one, which is what makes it survive leaving the session and - // reopening it. See `compactingLabel`: null is still the honest answer for a session whose - // status was never reported as compacting at all. + // When the current compaction started. The moment comes off the `compacting` status event + // itself -- the server timestamps every transcript line -- rather than off this device noticing + // one, which is what makes it survive leaving the session and reopening it. var compactingSince by remember { mutableStateOf(null) } var compactingFor by remember { mutableStateOf(null) } var streamError by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } // Whether the composer's process button has a request out. What it does next is decided from - // the session's status, and the status only changes once the server has answered and the - // stream has carried it back -- so two presses in that gap are two requests, both decided - // against the state before either of them. The server refuses the second one, but a control - // that can be pressed while its own last press is still in flight is asking to be. + // the session's status, and that only changes once the server has answered and the stream has + // carried it back -- so two presses in that gap are two requests, both decided against the + // state before either of them. var processInFlight by remember { mutableStateOf(false) } val context = LocalContext.current // Seeded from what was left in the box last time and written back on every keystroke, so - // leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed - // message. See `Drafts.kt` for why this one piece of state is the device's rather than the - // server's. + // leaving the screen does not throw away a half-typed message. See `Drafts.kt`. var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) } // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching - // makes the session re-read the whole conversation, which is worth asking about first. + // makes the session re-read the whole conversation. var pendingModel by remember { mutableStateOf(null) } - // What was last taken from the command suggestions, so the list closes behind it; see - // [CommandSuggestions] at its call site. + // What was last taken from the command suggestions, so the list closes behind it. var picked by remember { mutableStateOf(null) } // The transcript's selection, held here rather than inside [TranscriptList] because the rows - // have to ask whether anything is selected before they treat a tap as their own -- see - // [expanding]. + // have to ask whether anything is selected before they treat a tap as their own. val selection = rememberSelectionState() // Read here, at composition, rather than inside [expanding] at the moment of the click. The - // container clears the selection from the very press that a card then reads as its own, a few - // milliseconds earlier and in the same event -- so a card asking the live state at its click - // always hears "nothing is selected", and a tap meant to put a selection away also shut the - // tool call the words were in. This value is whatever was true as of the last frame, which is - // what the reader was looking at when they touched the screen. + // container clears the selection from the very press a card then reads as its own, a few + // milliseconds earlier and in the same event -- so a card asking the live state always hears + // "nothing is selected", and a tap meant to put a selection away also shut the tool call the + // words were in. This is whatever was true as of the last frame. val selecting = selection.selectedTexts.isNotEmpty() var expandedTools by remember { mutableStateOf(setOf()) } - // Which runs of adjacent tool calls are open. Keyed by the first call's - // id, so a group survives more calls arriving after it. + // Which runs of adjacent tool calls are open. Keyed by the first call's id, so a group survives + // more calls arriving after it. var expandedGroups by remember { mutableStateOf(setOf()) } - // Runs that have already been drawn as a group, so the transition into one is noticed exactly - // once. See the effect below. + // Runs already drawn as a group, so the transition into one is noticed exactly once. var everGrouped by remember { mutableStateOf(setOf()) } - // Which messages from other agents are open, by the seq that identifies their row. Closed - // by default, which is the rule for anything new in this transcript: a screen that opens - // everything it can is one nobody can scan. + // Which messages from other agents are open, by the seq that identifies their row. Closed by + // default, which is the rule for anything new in this transcript. var expandedNotes by remember { mutableStateOf(setOf()) } - // Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default - // like everything else new in this transcript, and held here rather than in the card so a + // Which memory notes are open, by the note's own text. Held here rather than in the card so a // note opened and scrolled past is still open on the way back. var openMemories by remember { mutableStateOf(setOf()) } // The image being looked at full screen, by ref. Here rather than in the row that drew the - // thumbnail: a row regrouped underneath the reader takes its whole subtree with it, and the - // dialog with it -- see [SessionImageViewer]. + // thumbnail: a row regrouped underneath the reader takes its whole subtree with it. var fullImage by remember { mutableStateOf(null) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } - // What this session is set to now, seeded from the row that opened it and - // then owned here, because changing either is something this screen does. - // The name shown at the top. Held here rather than read from the row that opened this - // screen, because renaming is something this screen can do -- through the settings below it, - // or by typing the command -- and a header still showing the old name reads as a rename that - // did not take. + // The name shown at the top. Held here rather than read from the row that opened this screen, + // because renaming is something this screen can do -- and a header still showing the old name + // reads as a rename that did not take. var title by remember(summary.id) { mutableStateOf(summary.title) } var model by remember { mutableStateOf(summary.model) } var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } - // The models this provider actually offers, asked of the server rather - // than listed here: a hardcoded list is a claim about a machine. + // The models this provider actually offers, asked of the server rather than listed here: a + // hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It - // keys everything that describes one visit to this session: the source below, the opening - // effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8. + // keys everything describing one visit: the source, the opening effect, the stream, and the + // anchor. See TRANSCRIPT_CACHE.md's decision 8. var epoch by remember(summary.id) { mutableIntStateOf(0) } // This server's cached transcripts, and this session's half of them. The cache is per server // because two servers can hold a session with the same id; the source is per visit because @@ -337,77 +297,58 @@ fun SessionScreen( remember(summary.id, epoch) { TranscriptSource(settings, summary.id, cache.session(summary.id)) } - // Whether the cached tail has been shown to still be the server's own line. Nothing is - // resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe - // that could not be made leaves this false for the stream loop to try again. + // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed + // from a cached cursor until it has, and a probe that could not be made leaves this false for + // the stream loop to try again. var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } // Whether the opening effect is still settling that question. It draws the cached rows and // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream - // below has to wait for this rather than for `ready`, or it asks the very same question a - // second time and races the answer. + // below waits for this rather than for `ready`, or it asks the same question twice. var probing by remember(summary.id, epoch) { mutableStateOf(true) } - // The oldest sequence number loaded, and whether there is more behind - // it. Paging backwards is what keeps opening a long session cheap: the - // screen starts with the end of the conversation and fetches earlier - // pages only when somebody scrolls to them. + // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is + // what keeps opening a long session cheap. var oldestSeq by remember { mutableLongStateOf(0L) } - // Where this session was last being read, from this device's own store. Read once, because - // it is the question "where did I leave off" and the answer stops being interesting the - // moment the list is on screen. + // Where this session was last being read, from this device's own store. Read once, because the + // answer stops being interesting the moment the list is on screen. val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } - // Whether the saved position is still being put back -- the history it needs fetched, and the - // scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling - // to the anchor is exactly the journey a reader must never see, and this transcript is not - // allowed to move under one. + // Whether the saved position is still being put back. Nothing is drawn while it is: opening at + // the newest end and then travelling to the anchor is exactly the journey a reader must never + // see. var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } - // Sent, but not yet read by the session -- which is when the backend - // records it and it comes back as a row. Until then it is drawn below - // the working indicator, because that is where it is in the session's - // reading of events: after everything taken in, not yet taken in - // itself. // Messages the server has taken and the session has not read yet, by the id that will resolve - // them. From the event stream rather than from what this screen sent, so they are still here - // after leaving the session or restarting the app -- and so a message sent from another device - // is drawn waiting on this one too. + // them. From the event stream rather than from what this screen sent, so they survive leaving + // the session -- and a message sent from another device is drawn waiting on this one too. var queued by remember { mutableStateOf(listOf()) } - // Commands the session has been asked to run and cannot yet, by the id that will resolve - // them. From the server rather than from this screen, so a rename sent from the settings - // screen -- or from another device -- is drawn waiting here too. + // Commands the session has been asked to run and cannot yet, by the id that will resolve them. + // From the server, so a rename sent from another device is drawn waiting here too. var waitingCommands by remember { mutableStateOf(listOf>()) } val running = sessionWorking(status) var moreHistory by remember { mutableStateOf(true) } var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } - // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because - // it describes that session's rows and nothing else. + // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. val replies = remember(summary.id) { ParsedReplies() } - // Keyed like everything else that describes one session's transcript. `rememberLazyListState` - // saves through `rememberSaveable`, and this screen restores by its own anchor instead -- - // two restores would fight over the first frame. + // Keyed like everything else describing one session's transcript. `rememberLazyListState` saves + // through `rememberSaveable`, and this screen restores by its own anchor instead -- two + // restores would fight over the first frame. val listState = remember(summary.id) { LazyListState() } - // Whether the newest message is on screen right now. The list is reversed, so the newest end - // is the scrolling start: nothing behind you is exactly being at the bottom. Asked of the - // scroll state rather than of item indices, because a zero-height first item (the empty - // "below" slot) makes an index ambiguous about where the viewport actually is. - // - // This is what the jump-to-newest button watches, and the gate on recording -- see [record]. + // Whether the newest message is on screen right now. The list is reversed, so the newest end is + // the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll + // state rather than of item indices, because a zero-height first item makes an index ambiguous. + // This is what the jump-to-newest button watches, and the gate on recording. val atNewest by remember { derivedStateOf { !listState.canScrollBackward } } // Transcript events that arrived while somebody was reading further back, in the order they - // arrived, waiting for them to return to the newest end. See [record] for why. + // arrived, waiting for them to return to the newest end. See [record]. var held by remember { mutableStateOf(listOf()) } - // What is actually drawn: the transcript with runs of adjacent tool - // calls folded into one row each, flattened into the list's units. + // What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. val rows = remember(items) { groupToolRuns(items) } - // Bumped when a cold reply's parses become ready, so the flatten runs again and can split - // it; see [unwarmedReplies]. + // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. var warmedTick by remember { mutableIntStateOf(0) } val units = remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) } // The reply that just finished streaming is the one row whose parses nobody has made: pages - // warm before their fold lands, but nothing warms live deltas. Off the composing thread, - // then the tick re-flattens -- so settling never costs a whole-message parse in a frame. - // Re-launched per fold and almost always finds nothing; during streaming the last row is - // unsettled and not wanted. + // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then + // the tick re-flattens, so settling never costs a whole-message parse in a frame. LaunchedEffect(rows) { val cold = unwarmedReplies(rows, replies) if (cold.isNotEmpty()) { @@ -416,8 +357,7 @@ fun SessionScreen( } } // The same list, readable from effects launched before this composition: an effect's closure - // keeps the values of the composition that launched it, and both the anchor saver and the - // restore need the units as they are *now*. + // keeps the values of the composition that launched it. val currentUnits by rememberUpdatedState(units) val lastTouch = remember { LastTouch() } @@ -427,14 +367,13 @@ fun SessionScreen( * * One function rather than a clearing written at each of the three places that need it -- a * stream reset, a cached transcript the server turns out not to have, and Reload -- because - * what has to go is a property of "these rows are no longer continuous with what comes next", - * not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both - * are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap - * draws a bubble waiting for a message the session read long ago. [contextTokens] needs no - * clearing, because `UsageDelta.context` is absolute and the next one corrects it. + * what has to go is a property of "these rows are no longer continuous with what comes next". + * The two easy ones to leave out are [queued] and [waitingCommands]: both are folded from + * events, so a `messageQueued` whose resolving `userMessage` fell in the gap draws a bubble + * waiting for a message the session read long ago. [contextTokens] needs no clearing, because + * `UsageDelta.context` is absolute. * - * The resume cursor is deliberately *not* cleared here: a reset continues from where it was, - * and only a caller that is starting the conversation again from the server says so itself. + * The resume cursor is deliberately *not* cleared: a reset continues from where it was. */ fun dropLoadedTranscript() { items = listOf() @@ -453,44 +392,37 @@ fun SessionScreen( * leading edge of its first visible item, which in this upside-down layout is that item's * *bottom* -- so a row that grows pushes everything already on screen upwards, and the view * travels toward the newest end without anybody scrolling. Measured against a reply streamed in - * four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very - * bottom, forty lines further on than where it was left. + * four hundred pieces: scrolling back a screen and waiting six seconds ended at the very + * bottom. * - * Insertions were never the problem -- the list is keyed, so a row arriving at either end - * leaves the anchor where it is, and reading back through history while a session works has - * always been still. What cannot be allowed is a row that is already there changing height, and - * the one guarantee that covers every way that happens -- a reply streaming, a tool's output - * arriving, a queued bubble appearing above the anchor -- is to change nothing at all while - * somebody is reading further back. + * Insertions were never the problem -- the list is keyed. What cannot be allowed is a row that + * is already there changing height, and the one guarantee covering every way that happens is to + * change nothing at all while somebody is reading further back. */ fun record(entry: SeqEvent) { - // The oldest event this view holds, which is what paging backwards - // starts from. Maintained here rather than by each loader: the - // first page and a stream reset both begin an empty view, and one - // of them getting it wrong is a transcript that will not scroll up. + // The oldest event this view holds, which is what paging backwards starts from. Maintained + // here rather than by each loader: the first page and a stream reset both begin an empty + // view, and one of them getting it wrong is a transcript that will not scroll up. if (oldestSeq == 0L) { oldestSeq = entry.seq moreHistory = entry.seq > 1L } val event = entry.event - // The message coming back is the session saying it has - // read it, so the bubble held below the indicator becomes - // the row `foldEvent` is about to add. - // Waiting, then read. Matched by id: the same message sent twice is two - // bubbles, and clearing by text would take away whichever matched first. + // Waiting, then read. Matched by id: the same message sent twice is two bubbles, and + // clearing by text would take away whichever matched first. if (event is SessionEvent.MessageQueued) { queued = queued + QueuedMessage(event.id, event.text, event.attachments) } if (event is SessionEvent.UserMessage) { queued = queued.filterNot { it.id == event.id } } - // Waiting, then taken back. From the server rather than from the tap, so every device - // drops the bubble and a reconnect does not put back one that was cancelled. + // Waiting, then taken back. From the server rather than from the tap, so every device drops + // the bubble and a reconnect does not put back one that was cancelled. if (event is SessionEvent.MessageDropped) { queued = queued.filterNot { it.id == event.id } } - // Waiting, then gone: a command leaves this list when the session takes it, - // and the row it becomes is added by `foldEvent` in the same pass. + // Waiting, then gone: a command leaves this list when the session takes it, and the row it + // becomes is added by `foldEvent` in the same pass. if (event is SessionEvent.CommandQueued) { waitingCommands = waitingCommands + (event.id to event.text) } @@ -510,26 +442,23 @@ fun SessionScreen( */ fun apply(entry: SeqEvent) { lastSeq.set(entry.seq) - // Before the rest, and for every event rather than only the usage ones: a compaction and - // a clear move this as much as a turn does, which is the whole reason it is a fold and - // not a running total. See `contextAfter`. + // Before the rest, and for every event rather than only the usage ones: a compaction and a + // clear move this as much as a turn does. See `contextAfter`. contextTokens = contextAfter(contextTokens, entry.event) when (val event = entry.event) { - // Nothing further: what it carries was folded into the context above, and what a - // turn cost is not something the transcript draws. + // Nothing further: what it carries was folded into the context above. is SessionEvent.UsageDelta -> {} else -> { - // What the session says it is set to now, which is the only thing that - // says it: picking from either menu asks, and the answer comes back here. + // What the session says it is set to now, which is the only thing that says it: + // picking from either menu asks, and the answer comes back here. if (event is SessionEvent.Settings) { event.model?.let { model = it } event.permissionMode?.let { permissionMode = it } } if (event is SessionEvent.Status) { // The event's own timestamp, so a compaction that began before this screen - // opened is timed from when it actually began. Timing it from the moment we - // arrived would report the wait as shorter than it was, in exactly the case - // somebody is asking about -- a compaction worth asking about is a long one. + // opened is timed from when it actually began -- and a compaction worth asking + // about is a long one. compactingSince = when { event.state != "compacting" -> null @@ -552,14 +481,11 @@ fun SessionScreen( * The transcript is one [SelectionContainer], so a reader who has selected some text puts that * selection away by tapping -- and the tap that does it lands on whatever card the text is * drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection - * and the tool call under their finger collapses, which is a second thing happening for a - * gesture that meant one. So a press with a selection outstanding spends itself clearing it and - * does nothing else, and the press after that -- with nothing selected -- opens or closes as - * usual. + * and the tool call under their finger collapses. So a press with a selection outstanding + * spends itself clearing it and does nothing else. * - * Every open and close on this screen goes through here rather than each writing the check, - * since which card the finger lands on is not something the reader chose and the rule cannot - * hold for only some of them. + * Every open and close goes through here rather than each writing the check, since which card + * the finger lands on is not something the reader chose. */ fun expanding(toggle: () -> Unit) { if (selecting) { @@ -574,20 +500,13 @@ fun SessionScreen( * * The transcript is laid out from the bottom, so every row's *bottom* edge is what the list * holds still and all growth goes upward. That is what a tap in a row's lower half already - * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not - * move, which is what the reader is looking at down there. A tap in the upper half is the other - * case -- left alone it sends the heading under the reader's finger up off the screen and fills - * the space above it, so the calls appear on the far side of the control that produced them -- - * and that one asks the row to hold its top edge instead. + * gets. A tap in the upper half is the other case -- left alone it sends the heading under the + * reader's finger up off the screen -- and that one asks the row to hold its top edge instead. * - * Which half decides it, rather than which control was pressed, so that everything that opens - * behaves the same way whether or not it happens to have a control at each end. A group has two - * and its heading and foot bar land in the halves they are already in; a single call is one - * card, and tapping low on an open one shuts it downward exactly as the bar does. + * Which half decides it, rather than which control was pressed, so everything that opens + * behaves the same way whether or not it has a control at each end. * - * The correction itself belongs to the measurement -- see [holdTopEdge]. Which half was touched - * comes from the row's own detector ([LastTouch]), written by the gesture that is about to run - * [toggle]. + * The correction itself belongs to the measurement -- see [holdTopEdge]. */ fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding { if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key @@ -597,18 +516,16 @@ fun SessionScreen( /** * Whether the row holding transcript position [seq] is loaded, with older history behind it. * - * "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row -- - * [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so - * putting the reader inside one leaves them where they were only until the next page lands, - * which was a screen and a half out. Any row that is not the oldest is final. + * "Behind it" is the part easy to leave out. The oldest loaded row is a half-row -- [joinPages] + * welds the other half on when the page before it arrives, and it grows -- so putting the + * reader inside one leaves them where they were only until the next page lands, which was a + * screen and a half out. Any row that is not the oldest is final. * * The last row starting at or before [seq], rather than one starting exactly there: the events - * behind a row can be regrouped between the save and the reopen -- a run of calls folds - * differently when a page boundary moves, and two halves of a reply become one message -- and - * the reader's place is inside whichever row now holds that seq, not gone. + * behind a row can be regrouped between the save and the reopen, and the reader's place is + * inside whichever row now holds that seq. * - * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is - * the composition's value and does not change under a running coroutine. + * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. */ fun anchorRow(seq: Long): Long? { val ordered = groupToolRuns(items) @@ -622,49 +539,38 @@ fun SessionScreen( * * Shared by the two things that page backwards -- somebody scrolling to the far end, and * putting the list back where it was left -- because they want the same page for the same - * reason and a second copy of this would be a second answer to "what is loaded". + * reason. * * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's * value, which does not change under a running one. */ suspend fun loadOlderPage(limit: Int = HISTORY_PAGE, coalesce: Boolean = true): Boolean { - // Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a - // harmless no-op: `before = 0` fetches the events before the first one, which is none, - // and an empty page is how this function is told it has reached the start of the - // conversation -- so it would latch `moreHistory` false and the session could never be - // paged back at all. + // Nothing is loaded, so there is no "before" to ask about -- and asking anyway is not a + // harmless no-op: `before = 0` fetches the events before the first one, which is none, and + // an empty page is how this function is told it has reached the start of the conversation. + // It would latch `moreHistory` false and the session could never be paged back at all. // - // The window it fires in is the first layout. `moreHistory` starts true, which puts the - // history spinner in the list, which makes `visibleItemsInfo` non-empty before a single - // event has arrived -- and with no units loaded the room ahead adds up to zero, so the - // pager fetches. On a loopback server the opening page beat it and nothing was ever - // wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won - // the race and the transcript stopped one page from its newest end with no spinner and - // nothing to say why. + // The window it fires in is the first layout: `moreHistory` starts true, which puts the + // history spinner in the list and makes `visibleItemsInfo` non-empty before a single event + // has arrived. On a loopback server the opening page beat it; at `--delay 150`, which is + // what a phone over the tunnel costs, it won the race. // - // Guarded here rather than at the two callers because it is a fact about the question, - // not about who is asking: the post-open fetch reaches it too, on the path where the - // opening page failed and left `oldestSeq` unset. + // Guarded here rather than at the two callers because it is a fact about the question. if (oldestSeq == 0L) return false - // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, - // and the fold is the expensive half: `foldEvent` returns a new list per event, so a page - // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around - // three hundred thousand element copies for one page, run on the main thread in the - // middle of the scroll that asked for it. It was affordable at eighty events and is not - // at eight hundred, which is why the page that made scrolling back reach the top made it - // stutter to get there. + // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, and + // the fold is the expensive half: `foldEvent` returns a new list per event, so a page is + // that many copies of a growing list -- around three hundred thousand element copies, run + // on the main thread in the middle of the scroll that asked for it. // // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one - // errand, and this way the page costs one context switch instead of three. Neither half - // touches anything the composition owns -- `older` and `earlier` are local, and the - // `items` read below happens back on the caller's thread, where the write does too. + // errand. Neither half touches anything the composition owns. val page = withContext(Dispatchers.IO) { val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce) if (older.isEmpty()) return@withContext null // Folded oldest-first into a list of their own, then put in front: `foldEvent` - // merges streaming text into the item before it, so replaying an older page - // through the live list would glue it onto the newest message rather than its own. + // merges streaming text into the item before it, so replaying an older page through + // the live list would glue it onto the newest message rather than its own. var earlier = listOf() older.forEach { entry -> if (entry.event !is SessionEvent.UsageDelta) { @@ -681,26 +587,22 @@ fun SessionScreen( oldestSeq = oldest moreHistory = oldestSeq > 1L // Joined here rather than above, because it is the one step that reads what is already - // loaded: `items` must be read where it is written, and it is a single pass over the two - // lists against the page's quadratic fold. + // loaded: `items` must be read where it is written. val joined = joinPages(earlier, items) - // After the join rather than on the page alone: a boundary that fell through a reply - // leaves `joinPages` holding a message made of both halves, and that text has existed for - // no time at all. Warming the page by itself warmed the two halves and missed the one - // thing drawn -- which showed up as a single 22ms parse surviving every page. + // After the join rather than on the page alone: a boundary that fell through a reply leaves + // `joinPages` holding a message made of both halves, and that text has existed for no time + // at all. Warming the page by itself warmed the two halves and missed the one thing drawn. warm(replies, joined) items = joined return true } - // A call opened on its own stays open when a second call in the same run turns it into a - // group. Until this, watching a Bash call and having the session make another one shut the - // one being read and folded it behind "Called 2 tools" -- the reader lost what they were - // looking at because something else happened. + // A call opened on its own stays open when a second call in the same run turns it into a group. + // Until this, watching a Bash call and having the session make another one shut the one being + // read and folded it behind "Called 2 tools". // - // Considered once per run, at the moment it first becomes a group, and never again: after - // that the group's own toggle owns it, and re-deriving this every time would re-open a group - // the reader had just shut while one of its calls was still expanded. + // Considered once per run, at the moment it first becomes a group, and never again: after that + // the group's own toggle owns it. LaunchedEffect(rows) { val fresh = rows.filterIsInstance().filter { it.id !in everGrouped } if (fresh.isEmpty()) return@LaunchedEffect @@ -710,10 +612,10 @@ fun SessionScreen( everGrouped = everGrouped + fresh.map { it.id } } - // A compaction reports nothing about its own progress -- measured against the CLI, which - // says it has started, and then says nothing at all until it is done. So what this counts is - // the one thing anybody here can measure: how long it has been going. A bar filling up would - // be this screen inventing the part the CLI does not send. + // A compaction reports nothing about its own progress -- measured against the CLI, which says + // it has started and then nothing at all until it is done. So what this counts is the one thing + // anybody here can measure: how long it has been going. A bar filling up would be this screen + // inventing the part the CLI does not send. LaunchedEffect(compactingSince) { val since = compactingSince if (since == null) { @@ -721,32 +623,26 @@ fun SessionScreen( return@LaunchedEffect } while (true) { - // Against this device's wall clock, because `since` is the server's -- the same - // comparison `relativeTime` already makes for a session's last activity. Floored at - // zero so a phone running a little behind the backend counts up from nothing rather - // than reporting a compaction that has not started yet. + // Against this device's wall clock, because `since` is the server's. Floored at zero so + // a phone running a little behind the backend counts up from nothing rather than + // reporting a compaction that has not started yet. compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0) delay(1000) } } - // The stream lifecycle: connect, follow, and on any drop reconnect - // from the cursor -- so a flaky link (or a backend restart) costs - // nothing but the gap's latency. - // The newest window first, before the stream opens, so the stream starts from where that - // window ended and carries live events only -- which is what it is good at. The window comes - // from this phone's own copy when there is one, and then costs a single request to check - // that the server's transcript is still the one it came from; otherwise it is a page fetched - // as it always was. See TRANSCRIPT_CACHE.md. + // The stream lifecycle: connect, follow, and on any drop reconnect from the cursor. + // + // The newest window first, before the stream opens, so the stream starts from where that window + // ended and carries live events only. The window comes from this phone's own copy when there is + // one, and then costs a single request to check that the server's transcript is still the one + // it came from. See TRANSCRIPT_CACHE.md. LaunchedEffect(summary.id, epoch) { /** * One opening window onto the screen, whichever side it came from. * * Warmed before the fold lands rather than after: flattening the rows into units splits - * every settled reply ([transcriptUnits]), and the flatten runs in the composition that - * first sees the rows. Folded into a scratch list off this thread to find out what needs - * warming; the real fold below also maintains the queue and the cursor, so it cannot be - * reused here. + * every settled reply, and the flatten runs in the composition that first sees the rows. */ suspend fun open(page: List) { withContext(Dispatchers.IO) { @@ -762,33 +658,31 @@ fun SessionScreen( } try { - // This phone's own copy first, drawn before anything is asked of the server -- which - // is the whole point of the cache. What makes it safe to draw before it is checked is - // that a failed check replaces these rows, with the same appearance as a reset. + // This phone's own copy first, drawn before anything is asked of the server. What makes + // it safe to draw before it is checked is that a failed check replaces these rows, with + // the same appearance as a reset. val cached = withContext(Dispatchers.IO) { source.cachedOpening() } if (cached != null) { open(cached) // A replay is as old as the last visit; the row this screen was opened from was // fetched moments ago. So the transcript comes from the cache and everything that - // is not the transcript comes from the summary, which is the newer measurement of - // the same thing -- otherwise a session that finished an hour ago opens saying - // "working" until the stream connects, which is a status row lying for a round - // trip. + // is not the transcript comes from the summary -- otherwise a session that finished + // an hour ago opens saying "working" until the stream connects. status = summary.status model = summary.model permissionMode = summary.permissionMode ?: "auto" if (summary.status != "compacting") compactingSince = null - // Nothing to put back, so these rows are the screen and the probe can return - // under them. A restore still has history to fetch and is gated below. + // Nothing to put back, so these rows are the screen and the probe can return under + // them. A restore still has history to fetch and is gated below. if (savedAnchor == null) ready = true } // The one thing a cached cursor has to be shown before the stream resumes from it. val usable = cached != null && withContext(Dispatchers.IO) { source.probe() } if (usable) probePassed = true if (!usable) { - // Either there was nothing cached, or what was cached is not what the server - // has -- the file was replaced or truncated under it. Same clearing as a reset, - // then an ordinary cold open. + // Either there was nothing cached, or what was cached is not what the server has -- + // the file was replaced or truncated under it. Same clearing as a reset, then an + // ordinary cold open. if (cached != null) { dropLoadedTranscript() lastSeq.set(0) @@ -799,11 +693,10 @@ fun SessionScreen( } } catch (e: ApiException) { // Not fatal: the stream below still replays from zero, which is slow but complete. - // Saying so beats silently showing nothing. // // It is also where a probe that could not be *made* lands -- a phone with no route to - // the server. Whatever was cached stays on screen and [probePassed] stays false, so - // the stream loop asks again before it resumes from that cursor. + // the server. Whatever was cached stays on screen and [probePassed] stays false, so the + // stream loop asks again before it resumes from that cursor. streamError = e.message } finally { // However that went, the stream is free to take it from here. @@ -811,50 +704,37 @@ fun SessionScreen( } try { - // Then back where reading stopped. An anchor deeper than the newest page is exactly - // the one worth restoring -- somebody who read to the bottom has no anchor at all -- - // and the cost was already paid on the way down there. + // Then back where reading stopped. An anchor deeper than the newest page is exactly the + // one worth restoring -- somebody who read to the bottom has no anchor at all. savedAnchor?.let { anchor -> // Pages until the anchor's row is loaded and has something older behind it. The - // oldest loaded row is a half-row: `joinPages` welds the other half onto it when - // the page behind it arrives, and it grows -- so anchoring into one puts the - // reader where they were only until the next page lands, which landed a screen - // and a half out. Any row that is not the oldest is final. + // oldest loaded row is a half-row that grows when the page behind it arrives, so + // anchoring into one puts the reader where they were only until that lands. // - // This terminates because `oldestSeq` walks strictly backwards and the anchor is - // a seq: once the window reaches past it, some loaded row starts at or before it - // and [indexOfSeq] answers. Keying on the row's *name* instead could not promise - // that -- a tool run is renamed whenever the newest page starts somewhere new, - // so an anchor on one was never found and this paged to the first event of the - // conversation every time an active session was reopened. + // This terminates because `oldestSeq` walks strictly backwards and the anchor is a + // seq. Keying on the row's *name* instead could not promise that -- a tool run is + // renamed whenever the newest page starts somewhere new, so an anchor on one was + // never found and this paged to the first event of the conversation every time. while (moreHistory && anchorRow(anchor.seq) == null) { // The whole span in one request rather than a page at a time. `read_window` // counts *lines* and a transcript numbers them one per seq, so the distance // back to the anchor is the number of events to ask for -- and were seqs ever - // sparse, that difference is larger than the count, which overshoots into - // older history rather than stopping short. [HISTORY_PAGE] on top is the - // cushion that keeps the anchor's row off the oldest edge, where it would - // still grow. + // sparse, that difference overshoots into older history rather than stopping + // short. // - // Capped, and the loop is what makes the cap safe: a span past it comes back - // in several requests instead of one, which is what this did for every - // restore until now -- thirteen sequential round trips to reopen a session - // somebody had read a little way back into, and a spinner for all of them. - // The bytes are the same either way, since every row between the anchor and - // the newest end has to be there for the list to be able to count to it. - // Raw, not coalesced: this counts events back to a known seq, and a page - // measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop - // bound it; see [loadOlderPage]. + // Capped, and the loop is what makes the cap safe: a span past it comes back in + // several requests instead of one, which is what this did for every restore + // until now -- thirteen sequential round trips to reopen a session somebody had + // read a little way back into. Raw, not coalesced: this counts events back to a + // known seq, and a page measured in rows cannot be counted to one. val behind = oldestSeq - anchor.seq val loaded = if (behind < 0) { // The anchor's row is loaded but is the oldest half-row, which - // [anchorRow] refuses; what completes it is the row before it, - // and only a page counted in rows can promise to reach that. - // Counted in events, the span here is negative and was coerced - // to one: a request per delta, walking a long reply back one word - // at a time -- six hundred round trips and a spinner for all of - // them, seen 2026-09-03 with an anchor inside a 1,400-delta reply. + // [anchorRow] refuses; what completes it is the row before it, and only + // a page counted in rows can promise to reach that. Counted in events + // the span is negative and was coerced to one: a request per delta, six + // hundred round trips for an anchor inside a 1,400-delta reply. loadOlderPage() } else { loadOlderPage( @@ -866,21 +746,16 @@ fun SessionScreen( } if (!loaded) break } - // Resolved to the row that *holds* the saved position rather than passed - // straight through, because the two are not always the same seq: the events - // behind a row regroup between the save and the reopen -- a run of calls folds - // differently when a page boundary moves, two halves of a reply become one - // message. Null is a row that is no longer in the transcript at all -- a reset - // stream, or a session cleared from elsewhere -- and means there is nothing to - // put back: the list is already at the newest end, which is where it opens. + // Resolved to the row that *holds* the saved position rather than passed straight + // through, because the two are not always the same seq: the events behind a row + // regroup between the save and the reopen. Null is a row no longer in the + // transcript at all, and means there is nothing to put back. anchorRow(anchor.seq)?.let { rowSeq -> - // The units are built by composition, and this coroutine has been loading - // rows the composition may not have seen -- so wait for the build that - // holds the anchor's row before turning it into an index. Guaranteed to - // arrive, because the row is in `items` and the units are a pure function - // of it. Nothing is drawn during the wait: [restoring] gates drawing, and - // the scroll is applied before it is lifted, so there is no frame showing - // anywhere else. One past the index, because item zero is the "below" slot. + // The units are built by composition, and this coroutine has been loading rows + // the composition may not have seen -- so wait for the build that holds the + // anchor's row before turning it into an index. Guaranteed to arrive, because + // the units are a pure function of `items`. Nothing is drawn during the wait. + // One past the index, because item zero is the "below" slot. val index = snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) } .first { it != null }!! @@ -892,16 +767,14 @@ fun SessionScreen( // than where they were, which is the state this screen opens in anyway. streamError = e.message } - // Whatever happened above, including a page that never arrived: an empty transcript is a - // state the screen can draw, and a permanently blank one is not. + // Whatever happened above: an empty transcript is a state the screen can draw, and a + // permanently blank one is not. restoring = false ready = true - // The opening page is sized for time-to-first-frame, not for reading: it fills a - // viewport or two, so the first "still loading" boundary sat barely off-screen and the - // first upward scroll met it and waited a round trip. The same reasoning that keeps the - // opening page off the critical path puts the first full page right behind it, while - // the screen is already up. A restore skips this: it has just paged as deep as the - // anchor needed. + // The opening page is sized for time-to-first-frame, not for reading: it fills a viewport + // or two, so the first "still loading" boundary sat barely off-screen and the first upward + // scroll met it and waited a round trip. So the first full page goes right behind it, while + // the screen is already up. A restore skips this: it has just paged as deep as it needed. if (savedAnchor == null && moreHistory && !loadingHistory) { loadingHistory = true try { @@ -912,41 +785,34 @@ fun SessionScreen( loadingHistory = false } } - // Last, and off this thread: this session is what must not be evicted, so it is marked - // as visited before the budget is applied, and both are a walk of the cache directory - // that nothing on screen is waiting for. + // Last, and off this thread: this session is what must not be evicted, so it is marked as + // visited before the budget is applied, and both are a walk of the cache directory. withContext(Dispatchers.IO) { source.cache.touch() cache.evictToBudget(keep = summary.id) } } - // Only while the screen is actually on screen. Android stops the - // activity when somebody switches away, and the socket dies with it -- - // which arrived as "Lost the event stream (SocketTimeoutException)" - // waiting at the top on their return. Switching apps is a choice - // somebody made, not a fault to report, and reconnecting on a phone - // that has been backgrounded is work nobody is watching. Stopping the - // stream deliberately makes the drop a close rather than an error (see - // EventStream.close), and resuming reconnects from the same cursor. + // Only while the screen is actually on screen. Android stops the activity when somebody + // switches away and the socket dies with it, which arrived as "Lost the event stream" waiting + // at the top on their return. Switching apps is a choice somebody made, not a fault to report. + // Stopping the stream deliberately makes the drop a close rather than an error, and resuming + // reconnects from the same cursor. LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { if (!ready) return@LaunchedEffect // The opening effect draws cached rows and lifts `ready` *before* it has checked that the - // cursor under them is still the server's, so `ready` is no longer the whole gate: this - // waits for that check to settle. Without it the two run at once, ask the same question - // twice, and race each other's answer -- two probes per warm open in the server's log. + // cursor under them is still the server's, so `ready` is no longer the whole gate. Without + // this the two run at once and race each other's answer -- two probes per warm open. snapshotFlow { probing }.first { !it } lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { while (true) { try { - // A cached cursor whose probe never got an answer, because the server - // could not be reached when the screen opened. Resuming a stream from an - // unchecked cursor is the one thing this must not do, so it is asked - // again here, on the reconnect schedule, with the cached rows still on - // screen meanwhile. False covers both answers that mean "open cold": - // the file is not the one these rows came from, and there was nothing - // cached to check. + // A cached cursor whose probe never got an answer, because the server could + // not be reached when the screen opened. Resuming from an unchecked cursor + // is the one thing this must not do, so it is asked again here with the + // cached rows still on screen. False covers both answers that mean "open + // cold". if (!probePassed) { if (withContext(Dispatchers.IO) { source.probe() }) { probePassed = true @@ -961,22 +827,17 @@ fun SessionScreen( withContext(Dispatchers.IO) { source.follow( after = lastSeq.get(), - // Connected, measured rather than inferred: this is what - // takes a failure off the screen, and nothing else does. - // Clearing on the first event instead meant an idle - // session kept displaying an error it had recovered from. + // Connected, measured rather than inferred: this is what takes a + // failure off the screen. Clearing on the first event instead meant + // an idle session kept displaying an error it had recovered from. onOpen = { streamError = null }, onReset = { - // Too far behind to continue from: what is on - // screen is a stale prefix of a conversation - // that has moved on, and the window arriving - // next is not adjacent to it. Dropping the rows - // is what makes this the same as opening the - // screen -- `apply` refills them, and scrolling - // up pages the rest back in as it always does. - // The cache needs no telling: the window's first - // seq is not the seq it was expecting, which is - // what closes its live run and starts another. + // Too far behind to continue from: what is on screen is a stale + // prefix of a conversation that has moved on, and the window + // arriving next is not adjacent to it. Dropping the rows makes + // this the same as opening the screen. The cache needs no + // telling: the window's first seq is not the one it expected, + // which closes its live run and starts another. dropLoadedTranscript() }, ) { entry -> @@ -984,13 +845,13 @@ fun SessionScreen( } } } catch (e: kotlinx.coroutines.CancellationException) { - // Leaving the screen or going below STARTED. Not a failure, and - // swallowing it would leave this loop reconnecting forever. + // Leaving the screen or going below STARTED. Not a failure, and swallowing + // it would leave this loop reconnecting forever. throw e } catch (e: Exception) { // Any failure, not only an [ApiException]: the stream reconnects from its - // cursor, so there is nothing a failure here can cost that is worth - // closing the app over. Reported on the screen either way. + // cursor, so there is nothing a failure here can cost that is worth closing + // the app over. streamError = e.message ?: e::class.simpleName } finally { source.close() @@ -998,23 +859,19 @@ fun SessionScreen( delay(RECONNECT_DELAY_MS) } } finally { - // Cancellation -- going below STARTED, or leaving the screen -- - // cannot interrupt a blocking socket read. Closing is what - // unblocks it, and what marks the drop deliberate. + // Cancellation cannot interrupt a blocking socket read. Closing is what unblocks + // it, and what marks the drop deliberate. source.close() } } } - // The screen going away entirely, which the lifecycle scope above does - // not cover: a composable can leave the composition while the activity - // stays started. Keyed on the epoch as well, so that Reload's replacement - // source is the one a later disposal closes. + // The screen going away entirely, which the lifecycle scope above does not cover: a composable + // can leave the composition while the activity stays started. Keyed on the epoch as well, so + // Reload's replacement source is the one a later disposal closes. DisposableEffect(summary.id, epoch) { onDispose { source.close() } } // Nothing gets announced about the session somebody is reading; see NotificationService. - // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left - // on this screen behind another app is one whose notifications are still wanted, and STARTED - // covers that case too. + // RESUMED rather than STARTED because "looking at it" means the foreground. LaunchedEffect(summary.id, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { NotificationService.showing(context, summary.id) @@ -1026,9 +883,9 @@ fun SessionScreen( } } - // Back at the newest end, so the backlog [apply] held can land. Everything at once rather - // than paced out: they are at the bottom, which is the one place the list is allowed to - // follow new content, and drip-feeding it would only make that following last longer. + // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than + // paced out: they are at the bottom, which is the one place the list is allowed to follow new + // content. LaunchedEffect(listState) { snapshotFlow { atNewest && held.isNotEmpty() } .collect { due -> @@ -1043,14 +900,11 @@ fun SessionScreen( // Driven by the position rather than by the scroll flag, and that is the whole point: a // *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never // observably changes and anything waiting for a settle never runs. Jump to latest is exactly - // that, and it left the old position recorded -- so the reader pressed the control that means - // "take me to the end", left, came back, and was put back where they had been. + // that, and it left the old position recorded. // - // The place is the first visible item -- in this reversed list, the one at the *bottom* of - // the viewport -- named by its row's seq and its unit within the row, which are the two - // things that survive a reopen. The index does not (the transcript is fetched newest-first), - // and the key does not either (a tool run is renamed when the newest page starts somewhere - // new); see [ScrollAnchor]. + // The place is the first visible item -- in this reversed list, the one at the *bottom* of the + // viewport -- named by its row's seq and its unit within the row, which are the two things that + // survive a reopen. The index does not, and the key does not either; see [ScrollAnchor]. LaunchedEffect(listState) { snapshotFlow { if (listState.isScrollInProgress) null @@ -1071,10 +925,8 @@ fun SessionScreen( saveScrollAnchor( context, summary.id, - // Nothing to restore at the newest end, which is where a session with no - // anchor opens anyway -- so the ordinary case costs a `remove` and no - // page-back on the way in. One *before* the index, because item zero is - // the "below" slot; a viewport starting inside it is at the newest end. + // Nothing to restore at the newest end, which is where a session with no anchor + // opens anyway. One *before* the index, because item zero is the "below" slot. if (!awayFromNewest) null else currentUnits.getOrNull(index - 1)?.let { @@ -1083,40 +935,35 @@ fun SessionScreen( ) } } - // Reaching within a few screens of the far end of what is loaded fetches the page before - // it. + // Reaching within a few screens of the far end of what is loaded fetches the page before it. // - // The question is pixels of scroll -- how far can the reader keep going before they run out - // -- and a lazy list cannot answer it exactly, because it has never measured the items it - // has not composed. So the room ahead is added up from the real size of every unit the - // list *has* laid out, kept by key as units pass through the viewport, with the running - // average standing in for the ones it has never seen. It used to be the average of the - // units currently on screen, and the units on screen are the worst possible sample: two - // tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and - // report screens of room when the end is one swipe away -- so the reader met the spinner - // at every boundary, which is exactly what the cushion exists to prevent. + // The question is pixels of scroll -- how far can the reader keep going before they run out -- + // and a lazy list cannot answer it exactly, because it has never measured the items it has not + // composed. So the room ahead is added up from the real size of every unit the list *has* laid + // out, kept by key as units pass through the viewport, with the running average standing in for + // the ones it has never seen. It used to be the average of the units currently on screen, which + // is the worst possible sample: two tall blocks fill a viewport, multiply out over dozens of + // unseen one-line rows, and report screens of room when the end is one swipe away. // - // There is no correction beside this one. Following the newest message is not an effect: - // the list is reversed, so an arriving message extends the end the viewport is pinned to, - // and a page of history lands past every visible index and moves nothing. + // There is no correction beside this one. Following the newest message is not an effect: the + // list is reversed, so an arriving message extends the end the viewport is pinned to. val unitSizes = remember(summary.id) { HashMap() } LaunchedEffect(listState, moreHistory) { snapshotFlow { listState.layoutInfo } .collect { info -> val visible = info.visibleItemsInfo if (visible.isEmpty()) return@collect - // Before the guards below, so sizes keep accumulating while a page is in - // flight and the next estimate starts better informed. + // Before the guards below, so sizes keep accumulating while a page is in flight and + // the next estimate starts better informed. visible.forEach { unitSizes[it.key] = it.size } if (restoring || !moreHistory || loadingHistory) return@collect val viewport = info.viewportSize.height if (viewport == 0) return@collect val loaded = currentUnits val average = unitSizes.values.sum() / unitSizes.size - // From the last visible lazy index: item zero is the "below" slot, so lazy - // index equals units index plus one -- starting the walk at `last().index` - // begins one unit past the last visible one, and a visible spinner makes the - // range empty, which is room of zero. + // From the last visible lazy index: item zero is the "below" slot, so lazy index + // equals units index plus one -- and a visible spinner makes the range empty, which + // is room of zero. var room = 0L val cushion = viewport.toLong() * HISTORY_SCREENS for (index in visible.last().index until loaded.size) { @@ -1125,10 +972,8 @@ fun SessionScreen( } loadingHistory = true try { - // One page, and then this fires again if it was not enough -- the estimate - // is re-made from what the page actually added, so a page that folds into - // almost no new units is followed by another because the room genuinely - // did not grow. + // One page, and then this fires again if it was not enough -- the estimate is + // re-made from what the page actually added. loadOlderPage() } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. @@ -1150,8 +995,7 @@ fun SessionScreen( .orEmpty() } } catch (_: Exception) { - // Not worth reporting: the picker simply has nothing to - // offer, which is visible, and the session is unaffected. + // Not worth reporting: the picker simply has nothing to offer, which is visible. emptyList() } } @@ -1161,14 +1005,13 @@ fun SessionScreen( * * Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is * what makes the cancellation the session's own fact rather than this screen's opinion of it -- - * a second device watching the same session has to lose the bubble too, and this one has to - * still lose it after a reconnect. + * a second device has to lose the bubble too, and this one has to still lose it after a + * reconnect. * * The refusal is kept on the message it was about rather than in [actionError]: the error row * lives under the header, and a bubble at the foot of the transcript is the thing that was - * pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session - * writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is - * waiting to be *read*, not waiting to be sent. + * pressed. It is the ordinary answer here -- a Claude session writes a steer into the CLI the + * moment it arrives, so what is on screen as "waiting" is waiting to be *read*. */ fun takeBack(messageId: String) { scope.launch { @@ -1196,14 +1039,10 @@ fun SessionScreen( /** * Opens or closes one peer message, from whichever of its pieces was pressed. * - * By seq rather than by unit, because an open message is several units and all of them shut it - * -- it was one card before it was several items, and which piece the finger landed on is not - * something the reader chose. + * By seq rather than by unit, because an open message is several units and all of them shut it. * * No [toggleAnchored] here, and that is the difference between growing a row and adding items: - * the list is keyed, so it holds the item it is anchored on wherever the new ones land. What - * the reader tapped keeps its place because the list keeps it, not because a measurement - * corrected it afterwards. + * the list is keyed, so it holds the item it is anchored on wherever the new ones land. */ fun togglePeer(seq: Long) = expanding { expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq @@ -1218,8 +1057,7 @@ fun SessionScreen( actionError = e.message } finally { // Whatever happened, including the failure above: a caller that re-enables a - // control here must get it back on the path where the request was refused too, - // or the refusal is what disables the control permanently. + // control here must get it back on the path where the request was refused too. onDone() } } @@ -1229,8 +1067,7 @@ fun SessionScreen( * Sends every answer a question card handed over, and says when the last of them has settled. * * All of them in one go because a card asks its questions together and the tool is waiting on - * all of them; the completion is what turns the card's spinner back into a button, whether the - * server took them or refused. + * all of them; the completion is what turns the card's spinner back into a button. */ fun answerAll(answers: List, onSettled: () -> Unit) { act(onDone = onSettled) { @@ -1244,14 +1081,14 @@ fun SessionScreen( if (text.isEmpty() && attachments.isEmpty()) return // A command is not a message: it is an instruction to the session about itself, and one // written into a running turn is read by the model instead. The server holds it until the - // turn ends and says so, which is where its waiting bubble comes from -- so nothing is - // held here, and there is no local guess to correct when the answer arrives. + // turn ends and says so, which is where its waiting bubble comes from -- so nothing is held + // here, and there is no local guess to correct. if (text.startsWith("/") && attachments.isEmpty()) { input = atEnd("") saveDraft(context, summary.id, "") - // The one command with a visible effect outside the transcript, applied when the - // server has accepted it rather than when it was typed: the name is this app's own - // datum and changes at once, and only telling the session waits for a boundary. + // The one command with a visible effect outside the transcript, applied when the server + // has accepted it rather than when it was typed: the name is this app's own datum and + // changes at once, and only telling the session waits for a boundary. val renamed = text.removePrefix("/rename ").trim().takeIf { text.startsWith("/rename ") && it.isNotEmpty() @@ -1265,16 +1102,14 @@ fun SessionScreen( input = atEnd("") saveDraft(context, summary.id, "") pendingAttachments = emptyList() - // Nothing is added here. The server says what is waiting -- it emits `messageQueued` - // when it takes a message it cannot deliver yet -- and this screen draws that. Holding a - // local copy as well was the bug: the two agreed only until the app was restarted or the - // session left, and then the screen showed nothing pending while the queue was full. + // Nothing is added here. The server says what is waiting -- it emits `messageQueued` when + // it takes a message it cannot deliver yet -- and this screen draws that. Holding a local + // copy as well was the bug: the two agreed only until the app was restarted. act { sendMessage(settings, summary.id, text, attachments) } } - // One path for everything attached, however it arrived: the photo picker, the file chooser - // or another app's share sheet. It uploads as soon as it is chosen, so Send only has ids to - // reference. + // One path for everything attached, however it arrived: the photo picker, the file chooser or + // another app's share sheet. It uploads as soon as it is chosen, so Send only has ids. fun attach(uri: Uri) { scope.launch { try { @@ -1282,7 +1117,7 @@ fun SessionScreen( withContext(Dispatchers.IO) { // An image is shrunk to what this session's provider takes before it is // uploaded, so a twelve-megapixel photo does not cross the tunnel to be - // rejected at the far end; a file goes whole -- see `uploadPicked`. + // rejected at the far end; a file goes whole. uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge) } pendingAttachments = pendingAttachments + id @@ -1301,7 +1136,7 @@ fun SessionScreen( uri?.let(::attach) } // What another app shared in, attached the moment this screen has it. Taken off the request - // first, so a recomposition or a return to this screen cannot attach it a second time. + // first, so a recomposition cannot attach it a second time. LaunchedEffect(share) { val incoming = share ?: return@LaunchedEffect onShareTaken() @@ -1312,23 +1147,19 @@ fun SessionScreen( } } - // One poll for the machines' limits, read by everything on this screen that reports them: - // the bar under the header, the colour of the button that opens the dialog, and the dialog. + // One poll for the machines' limits, read by everything on this screen that reports them. val usageFeed = rememberUsageFeed(settings) val usage = usageFeed.forSetup(summary.setup) RecordFrames() var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } - // The composer floats over the bottom of the screen instead of sitting under the transcript - // in one column, and the keyboard moves it by a layer translation rather than by relayout. - // With everything in one column under a root imePadding, every frame of the keyboard - // animation re-measured, re-placed and re-recorded the entire screen -- measured on the - // emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the - // Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a - // keyboard frame costs one layer transform for the composer and one re-measure of the - // transcript box, whose children skip measurement (width unchanged) and whose rows are - // already layers. + // The composer floats over the bottom of the screen instead of sitting under the transcript in + // one column, and the keyboard moves it by a layer translation rather than by relayout. With + // everything in one column under a root imePadding, every frame of the keyboard animation re- + // measured, re-placed and re-recorded the entire screen -- ~7.6ms of main-thread work per frame + // across ~34 frames per open on the emulator, and 82% late frames on the Pixel while the + // transcript itself cost 0.25ms. var composerHeight by remember { mutableIntStateOf(0) } val imeInsets = WindowInsets.ime val navInsets = WindowInsets.navigationBars @@ -1336,35 +1167,30 @@ fun SessionScreen( // rescues this from a real fault rather than merely reading the same thing twice. `imeInsets` // is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible` // is dispatched once, from the platform's own start/end of the transition, over a different - // path (`onApplyWindowInsets` rather than the animation callback). + // path. // - // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left - // the composer floating above the bottom of the screen for the rest of the session, with a - // bar of background colour showing under it and nothing that closed it. The likely cause is - // the animation callback that carries `imeInsets` back to zero being interrupted mid-flight -- - // a streaming reply invalidates the view every frame, which is exactly the condition known to - // starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and once that happens the - // stale, partway value it leaves behind has nothing left to correct it: the keyboard is not - // going to move again on its own. `isImeVisible` does not share that failure mode (it is not - // interpolated, so there is nothing for a dropped frame to interrupt), so it is what both - // places below fall back to. + // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left the + // composer floating above the bottom of the screen for the rest of the session. The likely + // cause is the animation callback that carries `imeInsets` back to zero being interrupted mid- + // flight -- a streaming reply invalidates the view every frame, which is exactly the condition + // known to starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and the stale + // partway value it leaves behind has nothing left to correct it. `isImeVisible` is not + // interpolated, so there is nothing for a dropped frame to interrupt. 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. + // Written here rather than beside the control that runs it, because everything it measures is + // this composable's own state and a control in a dialog cannot reach it. The control is a row + // in [SessionSettingsDialog], where the session's other about-the-session controls are. It + // copies rather than opens, because what it produces is a message to whoever is looking at the + // code. // // 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. + // 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 = { val report = debugReport( @@ -1390,15 +1216,12 @@ fun SessionScreen( ) 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. + // button copies. The clipboard is not reachable from a shell. 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. + // Emptied by the copy, so pressing it twice measures two separate stretches of scrolling. FrameStats.reset() DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() @@ -1410,43 +1233,37 @@ fun SessionScreen( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { GlyphButton(BACK_GLYPH, "Back", onBack) - // A ring's worth, which is what the arrow already keeps on its other three sides -- - // the pair of glyph buttons at the far end of this row get theirs from each other. + // A ring's worth, which is what the arrow already keeps on its other three sides. Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium) // Machine first, then what runs on it -- the same order and the same wording - // everywhere this pair appears, so it reads as one fact rather than as two - // sentences with different grammar. The "on" that used to sit in the middle - // made it a phrase, which only works in one order and stops working the moment - // the pair is shown anywhere else. + // everywhere this pair appears, so it reads as one fact rather than two + // sentences with different grammar. // // No model. The picker in the footer already shows what this session is set to, // and showing it twice means two things to keep in step -- they disagreed for a - // moment on every model change, since one follows the request and the other the - // session's own answer. + // moment on every model change. Text( "${summary.setupName} · ${summary.provider}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Beside the provider it reports on, which is the line directly to its left. + // Beside the provider it reports on, which is the line directly to its left. Its + // real home is this provider's settings, which do not exist yet. A session on a + // provider with no such service gets an honest "unavailable" rather than a hidden + // button -- a control that comes and goes makes its absence the signal, and absence + // cannot say why. // - // Its real home is this provider's settings, which do not exist yet; until they do, - // the session is the only place the provider is already named, so it is the only - // place the button can sit without inventing a scope for itself. What it shows is - // the paid service's own numbers, so a session on a provider with no such service - // gets an honest "unavailable" rather than a hidden button -- a control that comes - // and goes makes its absence the signal, and absence cannot say why. // Coloured by the worst window behind it, so the row says whether the limits are - // worth opening before anybody opens them. Blue at every ordinary level and only - // 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. + // worth opening before anybody opens them. 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 { GlyphButton( USAGE_GLYPH, @@ -1455,8 +1272,7 @@ fun SessionScreen( 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]. + // change" is. It opens *over* this screen rather than replacing it. GlyphButton( FOLDER_GLYPH, "Files", @@ -1467,24 +1283,22 @@ fun SessionScreen( 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. + // guessed at here, since this app does not know that home. 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 - // and not a word: there will be more, and a bar of words has nowhere to put it. + // own row. A cog and not a word because there will be more, and a bar of words + // has nowhere to put it. GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) } } // Under the header, above everything the session itself says: it is a fact about the // machine rather than a turn in the conversation, and it is the number that decides - // whether to keep going -- which was a screen away from where that gets decided. + // whether to keep going. SessionUsageBar(usage) (streamError ?: actionError)?.let { message -> @@ -1497,35 +1311,29 @@ fun SessionScreen( } // The transcript, reversed: item zero is the newest message and sits at the bottom, so - // the first frame of a session is already the right one, and following new content is - // where the list is rather than a correction it makes; see [TranscriptList]. + // the first frame of a session is already the right one; see [TranscriptList]. // // Drawn only once there is nothing left to put back. Held out of the drawing rather // than out of the composition, so the restore's scroll is applied against a list that - // is fully built, and there is no frame in which the transcript is somewhere other - // than where it was left. + // is fully built. val settled = !restoring Box( Modifier.weight(1f) .fillMaxWidth() // The room the floating composer needs, measured off it below -- reserving it // here is what lets the composer be an overlay without covering the newest - // message -- and then the keyboard's, per frame of its animation. This modifier - // is the whole of what the keyboard re-measures: the box's own size never - // changes, so nothing above it is touched. + // message. This modifier is the whole of what the keyboard re-measures: the + // box's own size never changes, so nothing above it is touched. .padding(bottom = with(LocalDensity.current) { composerHeight.toDp() }) // The keyboard's room, and only while the platform says there is a keyboard -- - // dropping the modifier is what coerces the stuck-open animated value to zero, - // the same guard the composer's translation applies below. It has to stay a - // *modifier* rather than a padding computed here: `imePadding` reads the inset - // in the layout phase, so a keyboard frame re-measures this box and nothing - // else, while reading `imeInsets` in this composable body subscribes the whole - // of `SessionScreen` to a value that changes every frame of the animation. - // That cost 16 full recompositions of this screen per keyboard open, against - // one, and it made the transcript's position depend on a recomposition landing - // inside the frame that the inset changed -- which the composer's does not, - // since its translation is re-read in that frame's draw phase. When the - // recomposition misses, the transcript trails the composer up the screen. + // dropping the modifier is what coerces the stuck-open animated value to zero. + // It has to stay a *modifier* rather than a padding computed here: `imePadding` + // reads the inset in the layout phase, so a keyboard frame re-measures this box + // and nothing else, while reading `imeInsets` in this composable body + // subscribes the whole of `SessionScreen` to a value that changes every frame + // -- 16 full recompositions per keyboard open against one, and the transcript's + // position behind a recomposition while the composer's stayed a draw-phase + // read. .then(if (imeVisible) Modifier.imePadding() else Modifier) ) { Box(Modifier.fillMaxSize()) { @@ -1538,11 +1346,9 @@ fun SessionScreen( Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, below = { // The last thing in the transcript, because that is where they are in - // the - // session's reading of events: after everything it has taken in, and - // not - // yet taken in themselves. What the session is *doing* about them is a - // line below, in [SessionStatusRow]. + // the session's reading of events: after everything it has taken in, + // and not yet taken in themselves. What the session is *doing* about + // them is a line below, in [SessionStatusRow]. if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { // The gap the arrangement no longer provides: this item sits flush // against the newest message otherwise. @@ -1563,10 +1369,9 @@ fun SessionScreen( pending = true, refusal = waiting.refusal, // The bubble goes away on the `messageDropped` this - // produces, not here: the server is what knows whether - // the message was still its to take back, and the - // other devices watching this session have to be told - // by the same event. + // produces, not here: the server knows whether the + // message was still its to take back, and the other + // devices have to be told by the same event. onTakeBack = { takeBack(waiting.id) }, ) } @@ -1600,16 +1405,13 @@ fun SessionScreen( Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> // A *request*, not a raw scroll delta: this runs inside // the measure pass that discovered the new height, and - // a - // raw delta forces a synchronous remeasure from within - // measure, which is fatal - // ("performMeasureAndLayout called during measure"). - // The request is applied by the same frame's next - // remeasure, so the correction still lands before - // anything is drawn. Reads unobserved, or this row's - // measure would inherit the scroll position as a - // dependency and remeasure on every frame of every - // fling. + // a raw delta forces a synchronous remeasure from + // within measure, which is fatal. The request is + // applied by the same frame's next remeasure, so the + // correction still lands before anything is drawn. + // Reads unobserved, or this row's measure would inherit + // the scroll position as a dependency and remeasure on + // every frame. Snapshot.withoutReadObservation { listState.requestScrollToItem( listState.firstVisibleItemIndex, @@ -1619,15 +1421,10 @@ fun SessionScreen( } } // Which half of this row the touch landed in, for - // [toggleAnchored]. - // On the initial pass and consuming nothing, so every - // control - // inside - // still gets the gesture exactly as it would have; only - // visible - // rows - // have one, which is what makes a detector per row - // affordable. + // [toggleAnchored]. On the initial pass and consuming + // nothing, so every control inside still gets the gesture; + // only visible rows have one, which is what makes a + // detector per row affordable. .pointerInput(row.key) { awaitEachGesture { val down = @@ -1655,12 +1452,8 @@ fun SessionScreen( }, isToolExpanded = { it in expandedTools }, // Anchored on the group, not the call: opening one - // call - // makes - // the whole group taller, and the heading the - // reader is - // under - // is the group's. + // call makes the whole group taller, and the + // heading the reader is under is the group's. onToolToggle = { id -> toggleAnchored(row) { expandedTools = @@ -1691,15 +1484,10 @@ fun SessionScreen( ) is TranscriptItem.AssistantMsg -> // A whole assistant row is only ever the reply - // still - // arriving -- every settled reply is flattened - // into - // block units instead; see [transcriptUnits]. - // Live - // is + // still arriving -- every settled reply is + // flattened into block units instead. Live is // what earns its blocks a layer each while - // deltas - // land. + // deltas land. AssistantMessage( item.text, replies, @@ -1757,11 +1545,9 @@ fun SessionScreen( is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.CompactedNote -> CompactedRow(item) - // Never reached: a peer message is flattened - // into its own units, so it is not a whole row. - // Here because a `when` over the item kinds has to - // stay exhaustive, and drawing nothing is how a - // row that stopped being handled would look. + // Never reached: a peer message is flattened into + // its own units. Here because a `when` over the + // item kinds has to stay exhaustive. is TranscriptItem.PeerNote -> PeerHeadRow(item, open = false, onToggle = {}) } @@ -1775,46 +1561,34 @@ fun SessionScreen( // Still finding out what this conversation is: the newest page has not arrived, or // it has and the list is being put back where reading stopped. Both draw no rows at // all, and a blank page is what this screen otherwise means by "there is nothing - // here" -- so the state that does not know needs its own appearance rather than - // sharing one with the empty answer. + // here" -- so the state that does not know needs its own appearance. // - // In the middle of the transcript rather than at either end, because it is not - // reporting on the newest message or the oldest; it is standing in for all of them. - // `settled` and not `restoring` alone, so the spinner covers the whole wait: - // fetching - // the history a saved position needs, and then the frames between those rows - // arriving - // and the layout that measures them putting the position back. They are the two - // halves - // of the same wait and the transcript is not drawn for either. + // In the middle of the transcript rather than at either end, because it is standing + // in for all of the rows. `settled` and not `restoring` alone, so the spinner + // covers the whole wait: fetching the history a saved position needs, and then the + // frames between those rows arriving and the layout that puts the position back. if (!ready || !settled) { CircularProgressIndicator( Modifier.align(Alignment.Center).size(LOADING_SPINNER) ) } - // Only while the newest message is off-screen. Reading back - // through a conversation is a place to be, not a state to be - // rescued from, so this waits to be wanted. + // Only while the newest message is off-screen. Reading back through a conversation + // is a place to be, not a state to be rescued from. // - // Down, and the same chevron a tool group collapses with: the - // list is built upside down internally, but nobody reading it - // knows that -- on screen the newest message is at the bottom, - // which is where this goes. The name is carried in the - // description, since an arrow alone says nothing to a screen - // reader and nothing to whoever finds this in six months. + // Down, and the same chevron a tool group collapses with: the list is built upside + // down internally, but nobody reading it knows that. The name is carried in the + // description, since an arrow alone says nothing to a screen reader. if (!atNewest) { Surface( // Instantly. An animated scroll travels the whole transcript, so the // further back somebody has read the longer this takes -- the one press // whose cost grows with how much there is to skip, which is backwards. // - // Arriving there is all this has to do now. The newest end is where the - // content hangs from, so being at it is the whole of following it, and - // there - // is no separate flag to set -- which is what this press used to forget, - // landing the reader at the bottom with new messages not bringing the view - // with them. + // Arriving there is all this has to do: the newest end is where the content + // hangs from, so being at it is the whole of following it. That is what + // this press used to forget, landing the reader at the bottom with new + // messages not bringing the view with them. onClick = { scope.launch { listState.scrollToItem(0) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -1833,13 +1607,11 @@ fun SessionScreen( } } - // Everything from here down floats: bottom-aligned over the transcript, moved up with - // the keyboard by a translation on its own layer. The translation is read inside the - // graphicsLayer block, so a keyboard frame invalidates layer properties only -- no - // measure, no recomposition, no re-recording of anything. Its height is reported to the - // transcript box above, which reserves that much room; the opaque background covers the - // one frame between this growing (a suggestion row, a second draft line) and that - // reservation catching up. + // Everything from here down floats: bottom-aligned over the transcript, moved up with the + // keyboard by a translation on its own layer. The translation is read inside the + // graphicsLayer block, so a keyboard frame invalidates layer properties only. Its height is + // reported to the transcript box above, which reserves that much room; the opaque + // background covers the one frame between this growing and that reservation catching up. Column( Modifier.align(Alignment.BottomCenter) .fillMaxWidth() @@ -1880,30 +1652,25 @@ fun SessionScreen( // Between the transcript and the box: above what is being typed, so the list does not // cover the thing the command is about, and below everything that explains it. CommandSuggestions( - // Nothing to suggest about a suggestion that was just taken. `/compact` is a - // whole command *and* a prefix of itself, so picking it left the list standing - // there with the one row already chosen -- the reader has to dismiss a list that - // has nothing left to offer, in front of the box they are about to send from. - // Held by what was picked rather than by a flag, so typing anything else brings - // the list back without needing a second thing to reset. + // Nothing to suggest about a suggestion that was just taken. `/compact` is a whole + // command *and* a prefix of itself, so picking it left the list standing there with + // the one row already chosen. Held by what was picked rather than by a flag, so + // typing anything else brings the list back without a second thing to reset. commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), onPick = { command -> - // At the end of what was inserted, which is where the reader carries on - // typing: a command with an argument is put in the box half-written, and a - // cursor left at the front makes the next keystroke the first character of - // "/rename" rather than of the name. + // At the end of what was inserted, which is where the reader carries on typing: + // a command with an argument is put in the box half-written, and a cursor left + // at the front makes the next keystroke the first character of "/rename". input = atEnd(command.typed()) picked = command.typed() }, ) - // Always enabled -- a send while the session is running becomes a - // steering message injected at the next tool boundary, which is - // the point of the whole app. + // Always enabled -- a send while the session is running becomes a steering message + // injected at the next tool boundary, which is the point of the whole app. // - // The field gets a row of its own, above the buttons: sharing one - // put the full width behind three controls, so the thing being - // typed into was the narrowest thing on the row. + // The field gets a row of its own, above the buttons: sharing one put the full width + // behind three controls, so the thing being typed into was the narrowest on the row. Column(Modifier.fillMaxWidth().padding(8.dp)) { // Directly above the box they will be sent from, so what is attached is visible // rather than counted: the "+2" on the button below said how many and never which. @@ -1929,8 +1696,8 @@ fun SessionScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - // Photo or file, asked here rather than by two buttons: the row is full, - // and attaching is one action whichever picker answers it. + // Photo or file, asked here rather than by two buttons: the row is full, and + // attaching is one action whichever picker answers it. var attaching by remember { mutableStateOf(false) } Box { // Just "+". The count it used to carry was standing in for showing them. @@ -1938,8 +1705,8 @@ fun SessionScreen( DropdownMenu( expanded = attaching, onDismissRequest = { attaching = false }, - // See PickerButton: without this the menu opens a status bar's - // height away from the button in an edge-to-edge activity. + // See PickerButton: without this the menu opens a status bar's height + // away from the button in an edge-to-edge activity. properties = PopupProperties(clippingEnabled = false), shape = BubbleMenuShape, ) { @@ -1963,12 +1730,11 @@ fun SessionScreen( ) } } - // The settings share what is left after the actions have - // taken what they need. A Row hands out intrinsic widths in - // order and clips whatever runs past the edge, so with - // these laid out first the arrival of Stop pushed Send off - // the screen entirely -- the app's central control, gone at - // exactly the moment the app is most in use. + // The settings share what is left after the actions have taken what they need. + // A Row hands out intrinsic widths in order and clips whatever runs past the + // edge, so with these laid out first the arrival of Stop pushed Send off the + // screen entirely -- the app's central control, gone at the moment it is most + // in use. Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), @@ -1981,12 +1747,11 @@ fun SessionScreen( // "default"; until this the list could not, so leaving it was a // one-way trip. options = listOf(DEFAULT_MODEL) + offeredModels, - // Not set here. The button follows what the session reports it - // is set to, which arrives a moment later and is sometimes a - // different answer -- a name the CLI resolved, or no change at all - // on a provider whose model is fixed when it starts. - // Asked about first, unless there is nothing to lose by it -- - // see [ModelSwitchWarning]. + // Not set here. The button follows what the session reports it is + // set to, which arrives a moment later and is sometimes a different + // answer -- a name the CLI resolved, or no change at all on a + // provider whose model is fixed. Asked about first, unless there is + // nothing to lose by it -- see [ModelSwitchWarning]. onPick = { chosen -> if ( modelLabel(chosen) == modelLabel(model) || @@ -2008,17 +1773,13 @@ fun SessionScreen( ) } // The same filled shape as the button beside it, not an outlined one: these are - // two things you can do about the session, and weighting one of them as - // secondary - // said they were a primary action and its qualifier. What separates them is the + // two things you can do about the session, and weighting one as secondary said + // they were a primary action and its qualifier. What separates them is the // colour and the mark, which is what they mean. // // Always here, rather than arriving with the turn as it used to. A control that - // comes and goes makes its own presence the signal, and its absence could not - // say - // whether there was nothing to do; a button that is always in the same place - // also - // cannot push Send off the end of the row by turning up. + // comes and goes makes its own presence the signal, and a button always in the + // same place also cannot push Send off the end of the row by turning up. val process = when { running -> ProcessAction.Pause @@ -2045,13 +1806,11 @@ fun SessionScreen( // The paper plane, with a clock on it while a turn is in flight: sending then // queues the message for the next tool boundary rather than starting a turn of // its own, and the two have to be told apart at a glance. The label says the - // same - // thing to a screen reader, which has nothing else to read. + // same thing to a screen reader. // // Disabled while there is nothing to send, rather than pressable and silent: // `send` has always returned early on an empty composer, so the button promised - // something it would not do, and the only feedback was the ripple. Disabled and - // not hidden, for the reason the button beside it is always here. + // something it would not do. Disabled and not hidden, for the reason above. Button( onClick = { send() }, enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), @@ -2069,16 +1828,16 @@ fun SessionScreen( } } - // Beside the other two dialogs, and outside the list for the same reason as them: what is - // open is the screen's business rather than any row's. See [SessionImageViewer]. + // Beside the other two dialogs, and outside the list for the same reason as them: what is open + // is the screen's business rather than any row's. See [SessionImageViewer]. fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } if (usageOpen) { UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) } if (settingsOpen) { - // Measured when the dialog opens rather than kept up to date: what the reader is being - // told is what pressing the button now would discard, and null until the walk of the - // directory returns is what not knowing looks like. + // Measured when the dialog opens rather than kept up to date: what the reader is being told + // is what pressing the button now would discard, and null until the walk of the directory + // returns is what not knowing looks like. var cachedBytes by remember(summary.id, epoch) { mutableStateOf(null) } LaunchedEffect(summary.id, epoch) { cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } @@ -2089,9 +1848,8 @@ fun SessionScreen( title = title, cachedBytes = cachedBytes, // The purge finishes before the epoch moves, because the relaunched opening effect - // reads the same directory and would otherwise draw what is about to be deleted. - // Everything else here is the clearing a cold open needs; the epoch is what makes it - // one, by rebuilding the opening effect, the stream, and the anchor being put back. + // reads the same directory and would otherwise draw what is about to be deleted. The + // epoch is what makes the rest a cold open. onReload = { settingsOpen = false scope.launch { @@ -2102,9 +1860,9 @@ fun SessionScreen( epoch++ } }, - // The header takes the new name at once and the dialog closes on it, because the - // rename has already been accepted by the server -- see [title], which is this app's - // own datum. The list behind this refetches on the way out of the session anyway. + // The header takes the new name at once and the dialog closes on it, because the rename + // has already been accepted by the server -- see [title], which is this app's own + // datum. onRenamed = { title = it settingsOpen = false @@ -2124,8 +1882,7 @@ private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send" * One value rather than four parallel conditions over the status, because the mark, the colour, the * name a screen reader is given and the request that goes out are four halves of one decision. A * button drawn as a pause that terminates the CLI is the worst bug available here, and separate - * branches over the same condition are how that happens -- these three each have to cover every - * case, and the compiler says so. + * branches over the same condition are how that happens. */ private enum class ProcessAction(val glyph: String, val label: String) { /** A turn is running: take it back, and leave the process holding the conversation. */ @@ -2155,8 +1912,7 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) = * One slice of a long user message, on the same bubble the first slice starts. * * Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a - * message long enough to be sliced has lines that wrap, so its bubble was at full width anyway -- - * see [USER_SPLIT_CHARS], which is what guarantees that. + * message long enough to be sliced has lines that wrap anyway -- see [USER_SPLIT_CHARS]. */ @Composable private fun UserChunkRow( @@ -2174,8 +1930,8 @@ private fun UserChunkRow( ) ) { Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer) - // The same arrangement [UserBubble] gives them: under the words, on the last slice - // because that is the bubble's bottom. + // The same arrangement [UserBubble] gives them: under the words, on the last slice because + // that is the bubble's bottom. unit.attachments.forEachIndexed { index, ref -> if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(settings, sessionId, ref, onOpenImage) @@ -2191,13 +1947,10 @@ private fun UserChunkRow( * * A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session * reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather - * than with the screen's other errors because this is where the reader pressed -- the error row is - * under the header, a screen away from the bubble they were looking at. + * than with the screen's other errors because this is where the reader pressed. * * A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one * `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into. - * Everything else keeps this bubble: short messages wrap their content, and the pending one keeps - * its take-back control. */ @Composable private fun UserBubble( @@ -2212,10 +1965,9 @@ private fun UserBubble( ) { Box(Modifier.fillMaxWidth()) { Card( - // A message the session has not read yet is drawn quieter than - // one it has. The difference is in degree -- said, not yet - // heard -- which is what colour alone can carry; where it sits - // is what says the rest. + // A message the session has not read yet is drawn quieter than one it has. The + // difference is in degree -- said, not yet heard -- which is what colour alone can + // carry. colors = CardDefaults.cardColors( containerColor = @@ -2229,16 +1981,16 @@ private fun UserBubble( if (onTakeBack == null) Modifier else Modifier.clickable(onClick = onTakeBack).semantics { - // The bubble is its own control and its own label; without this - // the only thing to read is the message, which does not say what + // The bubble is its own control and its own label; without this the + // only thing to read is the message, which does not say what // pressing it does. contentDescription = "Waiting to be read; tap to take it back" } ), ) { Column(Modifier.padding(12.dp)) { - // A message can be nothing but an attachment, and an empty line above a picture - // is a bubble with a gap in it for a sentence nobody wrote. + // A message can be nothing but an attachment, and an empty line above a picture is + // a bubble with a gap in it for a sentence nobody wrote. if (text.isNotEmpty()) { Text( text, @@ -2249,7 +2001,7 @@ private fun UserBubble( } // Under the words: what somebody wrote is what the bubble is, and the picture is // what they attached to it. It also keeps the first line of every bubble at the - // same place down the transcript, whether or not there is an image in it. + // same place down the transcript. attachments.forEachIndexed { index, ref -> if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(settings, sessionId, ref, onOpenImage) @@ -2271,8 +2023,7 @@ private fun UserBubble( * A message the server has accepted and the session has not read yet. * * [refusal] is why taking it back did not work, kept per message rather than on the screen: two - * bubbles can be waiting at once, and an error above them both would not say which one it was - * about. + * bubbles can be waiting at once, and an error above them both would not say which. */ private data class QueuedMessage( val id: String, @@ -2285,24 +2036,20 @@ private data class QueuedMessage( * Whether a model switch has anything to warn about -- see [ModelSwitchWarning]. * * What the warning is about is a *cache* being dropped, so the question is whether there is one. - * Two answers say there is not, and both used to produce the dialog anyway: - * - * A session whose process has exited has nothing running to hold a cache, so the next turn was - * always going to re-read the conversation -- the switch adds nothing to that bill. And a session - * reporting zero context is holding nothing, which is what `/clear` leaves behind. + * Two answers say there is not, and both used to produce the dialog anyway: a session whose process + * has exited has nothing running to hold a cache, and a session reporting zero context is holding + * nothing. * * Where the figure is *unknown* rather than zero the fallback is whether anything has been said * **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the - * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet - * and the conversation may be enormous. But a clear is the one case that makes the whole loaded - * transcript stop counting: it leaves the conversation on screen and takes it out of the session's - * context, and the server reports the context as unmeasured afterwards rather than as zero, since - * nobody has counted what is left. So the reading that used the whole list warned about dropping a - * cache that the clear had already dropped -- on the screen where a reader has just deliberately - * emptied the thing being warned about. + * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet. + * But a clear is the one case that makes the whole loaded transcript stop counting: it leaves the + * conversation on screen and takes it out of the session's context, and the server reports the + * context as unmeasured afterwards rather than as zero. So the reading that used the whole list + * warned about dropping a cache the clear had already dropped. * * With no clear anywhere in what is loaded this is the old reading exactly, which is the - * conservative answer for a clear that happened further back than the loaded window. + * conservative answer for a clear further back than the loaded window. */ private fun worthWarningAbout( status: String, @@ -2318,20 +2065,18 @@ private fun worthWarningAbout( /** * Asked before switching model, because switching is not free and the cost is invisible. * - * A model change drops the cached context: the next turn re-reads the entire conversation from the - * beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn - * before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing** - * from cache and created 41,509. On a long conversation that is the whole of it, again. + * A model change drops the cached context: the next turn re-reads the entire conversation and is + * charged for it. Measured on 2026-08-29 against a small session -- the turn before the switch read + * 30,771 tokens from cache and created 87; the turn after read **nothing** from cache and created + * 41,509. * * No number is offered here, deliberately. What it will cost depends on how long *this* - * conversation is, and this screen does not know that -- the running total beside it counts what - * has been spent, which is a different quantity. A figure worked out from it would be a guess in a - * measurement's clothes, and the reader could not tell which times it was right. + * conversation is, and this screen does not know that -- a figure worked out from what has been + * spent would be a guess in a measurement's clothes. * * The permission-mode picker beside it deliberately has no equivalent, which the same measurement * decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the - * reader that these dialogs can be clicked through, which is what makes the one that matters stop - * working. + * reader that these dialogs can be clicked through. */ @Composable private fun ModelSwitchWarning( @@ -2358,19 +2103,15 @@ private fun ModelSwitchWarning( /** * What the session is doing, and what the conversation has cost, on one line above the box. * - * A row of its own because both of these are facts about the session rather than turns in it, and - * both were previously drawn over the transcript: the token total floated in its bottom corner, - * where a long message ran underneath it, and the working indicator was an item inside the list, so - * it scrolled away exactly when somebody reading back wanted to know whether anything was still - * happening. Here they are always in the same place, and the thing they report on -- the session - * you are about to type at -- is directly below. + * A row of its own because both are facts about the session rather than turns in it, and both were + * previously drawn over the transcript: the token total floated in its bottom corner, where a long + * message ran underneath it, and the working indicator was an item inside the list, so it scrolled + * away exactly when somebody reading back wanted to know whether anything was still happening. * * The row is drawn whether or not it has anything to say. An empty one costs a line; a row that - * came and went would move the text box under the reader's thumb every time a turn started, and - * would make its own presence the signal for a state it never names. + * came and went would move the text box under the reader's thumb every time a turn started. * - * The states are the session's own status words plus the total, and each looks different from the - * others: `exited` is here because a session whose process is gone cannot be typed at, and with the + * `exited` is here because a session whose process is gone cannot be typed at, and with the * indicator gone from the list nothing else on this screen would say so. */ @Composable @@ -2390,16 +2131,14 @@ private fun SessionStatusRow( when (status) { // A bar rather than the spinner an ordinary turn gets, and it takes the row's whole // free width: nothing arrives in the transcript during a compaction, so this is the - // only thing on screen that is moving, and at a spinner's width that reads as a - // session that has hung. + // only thing on screen that is moving, and at a spinner's width that reads as a session + // that has hung. // // Indeterminate, which is a statement rather than an omission. The CLI says a - // compaction has begun and then says nothing at all until it has finished -- measured - // against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that - // took 23 seconds and produced not one line in between. So there is no fraction to - // fill, and a bar creeping along at the pace of the last one would be this screen - // inventing the part nobody sent it. Elapsed time is the only honest number here, and - // [compactingLabel] is where it is worded. + // compaction has begun and then nothing at all until it has finished -- measured on a + // real 80,346-to-2,088-token compaction that took 23 seconds and produced not one line + // in between. So there is no fraction to fill, and a bar creeping along at the pace of + // the last one would be this screen inventing the part nobody sent it. "compacting" -> { Text( compactingLabel(compactingFor), @@ -2416,9 +2155,9 @@ private fun SessionStatusRow( } "running" -> { CircularProgressIndicator( - // Smaller than the line beside it, so the row keeps the text's own height: - // a control taller than a line re-centres it and knocks it out of line with - // the total on the other end. + // Smaller than the line beside it, so the row keeps the text's own height: a + // control taller than a line re-centres it and knocks it out of line with the + // total on the other end. modifier = Modifier.width(12.dp).height(12.dp), strokeWidth = 2.dp, ) @@ -2430,12 +2169,11 @@ private fun SessionStatusRow( ) Spacer(Modifier.weight(1f)) } - // Every remaining state says which one it is, including the quiet one. The row used - // to name only `exited` and leave the rest blank, so a session sitting idle and one - // whose status nobody could read looked identical -- and a turn that had just been - // stopped showed nothing at all, which reads as the app having lost the session - // rather than as the stop having worked. The words are the session list's own, so - // one state is not called two things depending which screen you are on. + // Every remaining state says which one it is, including the quiet one. The row used to + // name only `exited` and leave the rest blank, so a session sitting idle and one whose + // status nobody could read looked identical -- and a turn that had just been stopped + // showed nothing at all. The words are the session list's own, so one state is not + // called two things depending which screen you are on. else -> Text( when (status) { @@ -2450,14 +2188,13 @@ private fun SessionStatusRow( modifier = Modifier.weight(1f), ) } - // How full the session is, which is the number a reader is asking about -- how much room - // is left before the next compaction -- rather than what has been spent getting here. + // How full the session is, which is the number a reader is asking about -- how much room is + // left before the next compaction -- rather than what has been spent getting here. // - // "unknown" in words, and always drawn. A context nobody has measured is not an empty - // one, and the two used to share an appearance: a session that had just been cleared, one - // whose provider never reports usage, and one that has not run a turn all showed nothing - // at all, which reads as a conversation with room to spare. It is the same reason the - // status word beside it names the quiet state instead of leaving the row blank. + // "unknown" in words, and always drawn. A context nobody has measured is not an empty one, + // and the two used to share an appearance: a session just cleared, one whose provider never + // reports usage, and one that has not run a turn all showed nothing at all, which reads as + // a conversation with room to spare. Text( contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown", style = MaterialTheme.typography.labelSmall, @@ -2491,9 +2228,8 @@ private fun QuestionRow( * * Everything that puts words in the box without the reader typing them goes through here: a * restored draft, a share arriving from another app, a slash command taken from the suggestions. - * All three leave the reader mid-sentence, and all three used to leave the cursor at whatever - * offset it happened to hold -- which for a box that has never been focused is the very start, so - * picking `/rename` and typing put the name in front of the command. + * All three used to leave the cursor at whatever offset it happened to hold -- which for a box that + * has never been focused is the very start, so picking `/rename` and typing put the name in front. */ private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) @@ -2502,8 +2238,7 @@ private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) * * Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of * events it separates. Deliberately not the platform's long-press timeout, which is the longest a - * tap can legally be: half a second of ignoring the button would start swallowing a deliberate - * reopen, and a press held that long to close a menu is not worth protecting at that price. + * tap can legally be: half a second of ignoring the button would swallow a deliberate reopen. */ private const val ONE_TAP_MS = 250L @@ -2518,22 +2253,18 @@ private fun PickerButton(current: String, options: List, onPick: (String var open by remember { mutableStateOf(false) } // When an outside touch last closed the menu. // - // Pressing this button while its own menu is open is such a touch. The menu is deliberately - // not focusable (see below), which means the press that dismisses it is also delivered to the - // window underneath -- and what it lands on there is this button. The dismissal arrives with - // the press and the click with the release, measured 3ms apart on the emulator, so a button - // that simply opened on every click would reopen what the same finger had just closed, and - // the menu could only be put away by tapping somewhere else. So the moment is remembered, and - // a click that follows it within one tap is read as the second half of that tap rather than - // as a new one. + // Pressing this button while its own menu is open is such a touch. The menu is deliberately not + // focusable (see below), so the press that dismisses it is also delivered to the window + // underneath -- which is this button. The dismissal arrives with the press and the click with + // the release, measured 3ms apart on the emulator, so a button that simply opened on every + // click would reopen what the same finger had just closed. var closedAt by remember { mutableLongStateOf(0L) } Box { BubbleButton( onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } ) { - // One line, truncated rather than wrapped: this sits in a row - // whose height is the buttons beside it, and a second line - // would move them. + // One line, truncated rather than wrapped: this sits in a row whose height is the + // buttons beside it, and a second line would move them. Text( current, style = MaterialTheme.typography.bodySmall, @@ -2544,22 +2275,18 @@ private fun PickerButton(current: String, options: List, onPick: (String // Two departures from the defaults, both deliberate. // // Not focusable, so opening it does not take focus from the message field and dismiss the - // keyboard. Changing the model mid-sentence is an aside, not a departure from what you - // were typing. + // keyboard. Changing the model mid-sentence is an aside. // // Not clipped, which is what puts the menu on the button instead of floating above it. // Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so // that window is the whole screen -- but asks whether the menu fits inside the *visible* // frame, which is the screen less the status and navigation bars. Two spaces, one - // comparison: sitting just above a button near the bottom then looks like an overflow, - // and the menu falls back to a fixed 48dp above the bottom of the visible frame. Measured - // on the emulator, that left the menu's foot 142px -- the status bar's height, exactly -- - // clear of the button that opened it. Turning clipping off makes both questions about the - // same window. What it gives up is that the keyboard stops counting as an edge: with the - // IME up the menu opens downwards over it rather than upwards over the transcript. That - // is the lesser fault -- it is still attached to the button that opened it, which is the - // whole complaint -- and correcting it would mean supplying a position provider, which - // this menu takes no parameter for. + // comparison: sitting just above a button near the bottom then looks like an overflow, and + // the menu falls back to a fixed 48dp above the bottom of the visible frame -- measured on + // the emulator as 142px, the status bar's height exactly, clear of the button that opened + // it. What this gives up is that the keyboard stops counting as an edge, so with the IME up + // the menu opens downwards over it. That is the lesser fault, and correcting it would mean + // supplying a position provider this menu takes no parameter for. DropdownMenu( expanded = open, onDismissRequest = { 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 2ffcdb0..4e116b2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -39,14 +39,11 @@ import kotlinx.coroutines.withContext * controls and hid the thing they act on. * * The model and the permission mode are deliberately still on the session's own bar, because those - * are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs - * with the thing it acts on. + * are changed *while* reading a turn -- "not this model, try that one". * - * Captions are for what a control costs rather than for what it is. Each control is a labelled noun - * with a switch or a field beside it, and a paragraph under every one of them made the dialog - * longer than the conversation it covers -- so Notifications has none, while Move and Reload do, - * because what those two take away is not visible from here. Failures get their words for the same - * reason: they are what the reader cannot work out by looking. + * Captions are for what a control costs rather than for what it is. A paragraph under every control + * made the dialog longer than the conversation it covers -- so Notifications has none, while Move + * and Reload do, because what those two take away is not visible from here. */ @Composable fun SessionSettingsDialog( @@ -66,7 +63,7 @@ fun SessionSettingsDialog( onDismiss: () -> Unit, /** * Copies what this session costs to draw. Built by the session screen, because everything it - * measures is that screen's own state -- see `copyRenderReport` there. + * measures is that screen's own state. */ onCopyRenderReport: () -> Unit, ) { @@ -76,16 +73,14 @@ fun SessionSettingsDialog( var error by remember { mutableStateOf(null) } // Null until the server has been asked. The row this dialog was opened over is a snapshot of // whenever the list was last fetched, so drawing the switch straight from it would show a - // position that may have been changed since -- from here or from another device -- with - // nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside - // it, which is what not knowing looks like: distinguishable from off, and from a refusal. + // position that may have been changed since. Until the answer arrives the switch is disabled + // and a spinner sits beside it, which is what not knowing looks like. var notify by remember(sessionId) { mutableStateOf(null) } var notifyError by remember { mutableStateOf(null) } - // Where the session works. Null until the server has been asked, for the same reason the - // switch above is: the row this dialog opened over is a snapshot, and a path drawn from it - // could be one somebody changed from another device. An empty answer is a session that was - // never given a directory, which is not the same as one whose directory is unknown -- the - // field is only enabled once one of those two is settled. + // Where the session works. Null until the server has been asked, for the same reason the switch + // above is. An empty answer is a session that was never given a directory, which is not the + // same as one whose directory is unknown -- the field is only enabled once one of those is + // settled. var cwd by remember(sessionId) { mutableStateOf(null) } var typedCwd by remember(sessionId) { mutableStateOf("") } var cwdError by remember { mutableStateOf(null) } @@ -98,8 +93,8 @@ fun SessionSettingsDialog( cwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty() } catch (e: ApiException) { - // Left unknown rather than falling back to the stale row: the switch stays - // disabled, instead of offering a position nothing confirmed. + // Left unknown rather than falling back to the stale row: the switch stays disabled, + // instead of offering a position nothing confirmed. notifyError = e.message notify = null } @@ -131,8 +126,8 @@ fun SessionSettingsDialog( } // Moved optimistically so the switch answers the finger that moved it, and put back if the - // request is refused -- a switch that waits for a round trip reads as broken on a slow - // tunnel, and one that stays moved after a refusal lies. + // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, + // and one that stays moved after a refusal lies. fun setNotify(wanted: Boolean) { val was = notify notify = wanted @@ -162,7 +157,7 @@ fun SessionSettingsDialog( onRenamed(chosen) } catch (e: ApiException) { // Reported here, where it happened, because this dialog is the only place that - // knows a rename was attempted -- the session behind it shows nothing about it. + // knows a rename was attempted. error = e.message saving = false } @@ -181,8 +176,8 @@ fun SessionSettingsDialog( singleLine = true, enabled = !saving, modifier = Modifier.fillMaxWidth(), - // The keyboard's own action does what the button does: a one-field form - // where the return key does nothing is a form people press return at anyway. + // The keyboard's own action does what the button does: a one-field form where + // the return key does nothing is a form people press return at anyway. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { save() }), ) @@ -207,8 +202,8 @@ fun SessionSettingsDialog( enabled = notify != null, ) } - // Beside the switch that failed, not with the rename's error: they are two - // requests and a reader has to be able to tell which one the server refused. + // Beside the switch that failed, not with the rename's error: they are two requests + // and a reader has to be able to tell which one the server refused. notifyError?.let { Text( it, @@ -225,9 +220,9 @@ fun SessionSettingsDialog( value = typedCwd, onValueChange = { typedCwd = it }, label = { Text("Working directory") }, - // What the field cannot say by being empty: a session that was never - // given one starts wherever its launcher does, and this names that - // rather than showing a path nobody chose. + // What the field cannot say by being empty: a session that was never given + // one starts wherever its launcher does, and this names that rather than + // showing a path nobody chose. placeholder = { Text("wherever the session was started") }, singleLine = true, enabled = cwd != null && !movingCwd, @@ -247,9 +242,8 @@ fun SessionSettingsDialog( } } // The whole of what pressing Move does, where it is about to be pressed. A - // directory is settled when the process is spawned, so there is no changing one - // under a running session -- it is ended, and the next thing said to the session - // starts it in the new place. + // directory is settled when the process is spawned, so it is ended and the next + // thing said to the session starts it in the new place. Text( "Moving stops the session's process. It starts again in the new directory " + "with the next message, or with Start.", @@ -269,10 +263,10 @@ fun SessionSettingsDialog( modifier = Modifier.fillMaxWidth(), ) { Text("Transcript", modifier = Modifier.weight(1f)) - // The size is what the button discards, and the unknown state is drawn - // rather than guessed: a spinner while the directory is being measured, and - // words when there is nothing there, because "nothing cached" and "0 B" read - // as different claims. + // The size is what the button discards, and the unknown state is drawn rather + // than guessed: a spinner while the directory is being measured, and words when + // there is nothing there, because "nothing cached" and "0 B" read as different + // claims. when { cachedBytes == null -> CircularProgressIndicator( @@ -325,8 +319,8 @@ fun SessionSettingsDialog( } } }, - // Disabled rather than absent while there is nothing to save: a button that comes and - // goes makes its own presence the signal, and its absence cannot say why. + // Disabled rather than absent while there is nothing to save: a button that comes and goes + // makes its own presence the signal, and its absence cannot say why. confirmButton = { TextButton(onClick = { save() }, enabled = changed && !saving) { Text(if (saving) "Saving..." else "Save") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index c2b3695..e292ea3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -34,20 +34,19 @@ sealed class SessionUsage { /** * This machine meters nothing, so there is no window to show. * - * Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or - * on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and - * not a failure to find something out. The backend never asks such a machine, so it returns no - * snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of - * answering with the nearest available word. Drawn as nothing, because there is nothing. + * Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a + * local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a + * failure to find something out. The backend never asks such a machine, and reading that + * silence as "couldn't find out" is answering with the nearest available word. */ data object NotMetered : SessionUsage() /** * The question could not be answered, and why. * - * Its own state because "we couldn't find out" and "none of it is used" are the pair that must - * never share an appearance: a bar sitting at zero because a machine is unreachable reads as - * plenty of headroom, which is the opposite of the truth. + * Its own state because "we couldn't find out" and "none of it is used" must never share an + * appearance: a bar sitting at zero because a machine is unreachable reads as plenty of + * headroom. */ data class Unavailable(val why: String) : SessionUsage() } @@ -59,17 +58,14 @@ private const val REFRESH_MS = 60_000L * One poll of every machine's limits, and the handle to ask again. * * A screen shows this answer in more than one place -- the bar under the session header, the colour - * of the button beside it, and the dialog that button opens -- and each of those used to fetch for - * itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole - * refresh interval old when the dialog opens with a fresh one, so the header read 42% while the - * screen over it read 47%, about a number somebody is deciding on. One feed per screen, and - * [refresh] moves both. + * of the button beside it, and the dialog that button opens -- and each used to fetch for itself. + * Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval + * old when the dialog opens with a fresh one, so the header read 42% while the screen over it read + * 47%. */ class UsageFeed( val snapshots: LoadState>, - /** - * A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed]. - */ + /** A fetch is outstanding. Only ever true over an answer already shown. */ val refreshing: Boolean, /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ val refresh: () -> Unit, @@ -93,15 +89,15 @@ class UsageFeed( fun rememberUsageFeed(settings: ServerSettings): UsageFeed { var snapshots by remember { mutableStateOf>>(LoadState.Loading) } var refreshing by remember { mutableStateOf(true) } - // Bumped to ask again now. The poll below restarts from the new value, so a manual refresh - // also resets the countdown to the next one rather than leaving one due immediately after. + // Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also + // resets the countdown rather than leaving one due immediately after. var asked by remember { mutableIntStateOf(0) } LaunchedEffect(asked) { while (true) { refreshing = true - // Replaces the answer only once the next one is in hand: dropping back to Loading - // would blank a bar somebody is reading for the length of a round trip, and what was - // on screen is still the last thing the machine actually said. + // Replaces the answer only once the next one is in hand: dropping back to Loading would + // blank a bar somebody is reading for the length of a round trip, and what was on + // screen is still the last thing the machine actually said. snapshots = try { LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) }) @@ -121,13 +117,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed { * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. * Taken over however many windows came back rather than the three Claude sends today -- the backend - * deliberately passes windows it does not recognise straight through, so a fourth one is a thing - * that happens rather than a thing to notice later. + * passes windows it does not recognise straight through. * * Every state that is not a measurement takes the ordinary control colour instead. That is the * point where colour stops being able to help: blue is the low end of a scale here, so colouring an - * unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind - * the button is where those say, in words, which one they are. + * unknown blue would say "measured, and fine" about a machine nobody could reach. */ @Composable fun usageGlyphColour(usage: SessionUsage): Color = @@ -145,18 +139,17 @@ fun usageGlyphColour(usage: SessionUsage): Color = * going, and it was a screen away from the place that decision gets made. It reports on this * session's machine alone -- the dialog is still where every machine is compared. * - * What it shows is the paid service's own metering, fetched from the machine that holds the - * account. It is never derived from what this app has watched go past: the transcript's token - * counts are a different quantity, measured differently, and a bar shaped like a quota gauge built - * out of them would be a guess wearing a measurement's clothes. + * What it shows is the paid service's own metering, never derived from what this app has watched go + * past: the transcript's token counts are a different quantity, measured differently, and a bar + * built out of them would be a guess wearing a measurement's clothes. */ @Composable fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { DebugStats.count("usage bar recomposed") // The countdown moves even when the numbers do not, so it is driven by a clock of its own // rather than recomputed at draw time: a percentage that comes back unchanged is an equal - // value, Compose skips the recomposition, and a "left" that only ticked when the quota - // happened to move would sit at a stale figure for hours. + // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved + // would sit at a stale figure for hours. var now by remember { mutableStateOf(OffsetDateTime.now()) } LaunchedEffect(Unit) { while (true) { @@ -165,8 +158,8 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { } } - // Nothing at all for a machine that meters nothing: a row saying "unknown" there would - // report a problem about a setup somebody chose, on every screen, forever. + // Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a + // problem about a setup somebody chose, on every screen, forever. if (usage is SessionUsage.NotMetered) { return } @@ -222,9 +215,8 @@ private fun UsageNote(text: String) { * The percentage on its own does not answer the question it gets asked, which is whether to start * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * - * The window's end has two missing cases and they are worded differently on purpose; see - * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is - * no countdown to report and inventing one would be the same fault as inventing the number. + * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window + * that is not running gets the percentage and nothing else. */ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { val percent = "${window.percent.toInt()}%" @@ -244,8 +236,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { /** * One machine's snapshot, out of every machine's. * - * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it: - * a machine nobody logged into, one that could not be reached, a snapshot that came back empty. + * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * machine having no quota rather than the question going unanswered. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index bb9c216..b067e57 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -47,15 +47,14 @@ fun SettingsScreen( val context = LocalContext.current var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") } var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) } - // Never pre-filled from the stored token: this screen shouldn't be a - // way to read the credential back off the device. + // Never pre-filled from the stored token: this screen shouldn't be a way to read the credential + // back off the device. var token by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> - // Null contents means the user backed out of the scanner -- not an - // error, so nothing to report. + // Null contents means the user backed out of the scanner -- not an error. val contents = result.contents ?: return@rememberLauncherForActivityResult val settings = parseEnrollmentUri(contents.toUri()) if (settings == null) { @@ -83,8 +82,8 @@ fun SettingsScreen( // left-pointing arrow at the right edge, aimed across the title it sits beside. // // Absent rather than disabled on first run, which is the one place this app lets a - // control come and go: there is no screen underneath yet, so a Back here would not be - // a capability being withheld but a promise it could not keep. + // control come and go: there is no screen underneath yet, so a Back here would not be a + // capability being withheld but a promise it could not keep. if (onBack != null) { GlyphButton(BACK_GLYPH, "Back", onBack) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) @@ -106,14 +105,11 @@ fun SettingsScreen( OutlinedButton( onClick = { - // Hold the camera permission before the scanner starts. - // Letting its activity ask on our behalf is what the - // library does by default, and it opens the camera without - // waiting for the answer: the first-ever scan comes up as - // a live preview with "Sorry, the Android camera - // encountered a problem" over it, and works on the second - // try. Nothing is wrong with the camera, so nothing should - // say there is. + // Hold the camera permission before the scanner starts. Letting its activity ask on + // our behalf is what the library does by default, and it opens the camera without + // waiting for the answer: the first-ever scan comes up as a live preview with + // "Sorry, the Android camera encountered a problem" over it, and works on the + // second try. if ( context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED @@ -187,11 +183,10 @@ fun SettingsScreen( * just been granted. * * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light - * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a - * dark-themed terminal it comes out as a photographic negative the scanner silently never matches. - * Which way round it renders is the terminal's business, not something this app should depend on. - * The mixed decoder alternates normal and inverted frames, costing half the frame rate at each - * polarity and nothing else. + * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark- + * themed terminal it comes out as a photographic negative the scanner silently never matches. The + * mixed decoder alternates normal and inverted frames, costing half the frame rate at each + * polarity. */ private fun enrollmentScanOptions(): ScanOptions = ScanOptions() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt index 23c2dc0..8bacde8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt @@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { LaunchedEffect(reloadToken) { reload() } Column(Modifier.fillMaxSize().padding(16.dp)) { - // The heading and Back are the tab row's now; adding a machine is this tab's own work - // and stays with the list it adds to. + // The heading and Back are the tab row's now; adding a machine is this tab's own work and + // stays with the list it adds to. Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = { adding = true }) { Text("Add machine") } } @@ -200,9 +200,8 @@ private fun SetupCard( Column(Modifier.padding(12.dp)) { Text(setup.name, style = MaterialTheme.typography.titleSmall) Text( - // Not "this machine": the seeded setup is *called* that, - // and the card read "this machine / this machine". The - // line has to say something the name cannot also be. + // Not "this machine": the seeded setup is *called* that, and the card read "this + // machine / this machine". setup.address ?: "runs where the backend does", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -275,9 +274,8 @@ private fun AddSetupDialog( OutlinedTextField( value = address, onValueChange = { address = it }, - // Just the shape. What a blank one means is said once, in the text above - // this form -- repeating it here wrapped the label onto a second line and - // made this field taller than the two beside it for no information. + // Just the shape. What a blank one means is said once, in the text above this + // form -- repeating it here wrapped the label onto a second line. label = { Text("user@host[:port]") }, singleLine = true, ) @@ -288,8 +286,7 @@ private fun AddSetupDialog( singleLine = true, ) // Where a file attached from the phone lands on that machine. Blank means the - // session's own directory, which is what most people want and what needs no - // path typed on a phone. + // session's own directory, which is what most people want. OutlinedTextField( value = attachmentsDir, onValueChange = { attachmentsDir = it }, @@ -309,9 +306,8 @@ private fun AddSetupDialog( }, dismissButton = { Row { - // Tried before saving, so a wrong address or an - // unauthorised key is caught while this form is still on - // screen rather than at the first spawn. + // Tried before saving, so a wrong address or an unauthorised key is caught while + // this form is still on screen rather than at the first spawn. TextButton( enabled = !testing, onClick = { @@ -377,14 +373,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) /** * Splits `user@host:port` into its two halves, with the port left null when none was typed. * - * One field rather than two because that is how an address is written and read everywhere else -- - * and because a port that is almost always 22 does not deserve a box of its own on a phone - * keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would - * put a second answer to that question in a second place. + * One field rather than two because that is how an address is written and read everywhere else, and + * because a port that is almost always 22 does not deserve a box of its own on a phone keyboard. + * Null rather than 22: the backend already decides the default. * * A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it, - * `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an - * address and a port. So the rule is: brackets, or exactly one colon followed by digits. + * `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon + * followed by digits. */ private fun splitHostAndPort(typed: String): Pair { if (typed.startsWith("[")) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt index daf4773..e21d3d6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt @@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat * * Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the * share arrives before anyone has said which. [serial] makes two shares of the same thing two - * requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose. + * requests, for the reason [SessionOpenRequest] carries one. */ data class ShareRequest(val uris: List, val text: String?, val serial: Int) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt index 2e5af76..081bda6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -4,15 +4,12 @@ package com.example.aiapp * A byte count at the coarsest unit that still says something, so rows stay comparable. * * Null at zero and below, because the screens that ask disagree about what nothing means and only - * the caller knows: a transcript of no bytes is a measurement that has not happened, and is left - * off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather - * than leaving a gap the reader would have to interpret; a session with no cached transcript says - * "nothing cached", because a figure of none would read as a measurement. + * the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no + * bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached + * transcript says "nothing cached", because a figure of none would read as a measurement. * * Its own file rather than the import screen's, where it started: three screens now say a size, and * a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B. - * `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two - * decimal places, which is a different question about a much larger number. */ fun humanSize(bytes: Long): String? = when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 699c81f..88a2f36 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext * The spawn screen: what to run, where to run it, and the per-kind fields. * * Providers and hosts both come from the server, so adding either to its config.ron shows up here - * with no app rebuild -- and because they are independent, any provider can be sent to any host. + * with no app rebuild. */ @Composable fun SpawnScreen( @@ -46,33 +46,27 @@ fun SpawnScreen( onBack: () -> Unit, ) { val scope = rememberCoroutineScope() - // What the form is made of, and whether we have it yet. A failure here - // is not the same as a server with nothing to offer, so it must not - // reach the pickers as empty lists -- see LoadState. + // What the form is made of, and whether we have it yet. A failure here is not the same as a + // server with nothing to offer, so it must not reach the pickers as empty lists. var options by remember { mutableStateOf>>(LoadState.Loading) } - // Setup first, then one of its providers. Choosing a setup can - // invalidate the provider, so the provider is stored by name and - // resolved against the current setup rather than held as an object - // that could outlive the list it came from. + // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the + // provider is stored by name and resolved against the current setup rather than held as an + // object that could outlive the list it came from. var setupName by remember { mutableStateOf(null) } var providerName by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") } - // "auto" rather than "manual": on a phone every ask is a round trip to - // a question card, and answering "allow Bash?" dozens of times per task - // is what this app exists to avoid. Manual stays one tap away for a - // session that warrants it. + // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and + // answering "allow Bash?" dozens of times per task is what this app exists to avoid. var permissionMode by remember { mutableStateOf("auto") } var busy by remember { mutableStateOf(false) } - // Only the spawn's own failure. The fetch's lives in `options`: this - // one leaves a filled-in form worth keeping, and that one leaves - // nothing to fill in. + // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in + // form worth keeping, and that one leaves nothing to fill in. var spawnError by remember { mutableStateOf(null) } - // Downloaded models, for a llama provider to choose between. Fetched - // beside the setups but kept separate: a Claude session needs none, so - // failing to list them must not stop the screen rendering. + // Downloaded models, for a llama provider to choose between. Kept separate from the setups: a + // Claude session needs none, so failing to list them must not stop the screen rendering. var models by remember { mutableStateOf>(emptyList()) } var modelKey by remember { mutableStateOf(null) } var contextSize by remember { mutableStateOf("") } @@ -105,10 +99,9 @@ fun SpawnScreen( } Spacer(Modifier.height(16.dp)) - // Nothing below is fillable until the options are here, and a - // failure to fetch them leaves no form worth showing -- so this - // reports and stops, rather than offering empty pickers under an - // error message. + // Nothing below is fillable until the options are here, and a failure to fetch them leaves + // no form worth showing -- so this reports and stops, rather than offering empty pickers + // under an error message. val setups = when (val state = options) { is LoadState.Loading -> { @@ -123,10 +116,9 @@ fun SpawnScreen( } val setup = setups.firstOrNull { it.name == setupName } val current = setup?.providers?.firstOrNull { it.name == providerName } - // Only the Claude CLI has models, a working directory and - // permission modes; keying the extra fields on the kind rather - // than the provider name keeps a second Claude provider from - // needing anything here. + // Only the Claude CLI has models, a working directory and permission modes; keying the + // extra fields on the kind rather than the provider name keeps a second Claude provider + // from needing anything here. val isClaude = current?.kind == "claude_cli" val isLlama = current?.kind == "llama_cpp" @@ -137,9 +129,9 @@ fun SpawnScreen( selected = setupName, onSelect = { name -> setupName = name - // The provider list changes with the machine, so a name - // carried over from the previous one would be a selection - // that isn't in the picker. Take that machine's first. + // The provider list changes with the machine, so a name carried over from the + // previous one would be a selection that isn't in the picker. Take that machine's + // first. providerName = setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name }, @@ -150,13 +142,13 @@ fun SpawnScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // The address belongs to the setup above it, not to the - // provider label below; without this they read as one block. + // The address belongs to the setup above it, not to the provider label below; without + // this they read as one block. Spacer(Modifier.height(8.dp)) } - // Only what this machine actually has. A setup with none says so - // rather than showing an empty row that reads as a failure. + // Only what this machine actually has. A setup with none says so rather than showing an + // empty row that reads as a failure. if (setup != null && setup.providers.isEmpty()) { Text( "\"${setup.name}\" has no providers configured.", @@ -183,10 +175,9 @@ fun SpawnScreen( ) if (isLlama) { - // A llama session names one of the models this backend has - // downloaded, so the choice is that list rather than free - // text -- there is nothing sensible to type here, and a name - // that is not on disk is a session that cannot start. + // A llama session names one of the models this backend has downloaded, so the choice is + // that list rather than free text -- a name that is not on disk is a session that + // cannot start. if (models.isEmpty()) { Text( "No models downloaded yet. Get one from the Models screen first.", @@ -196,9 +187,8 @@ fun SpawnScreen( } else { ChipGroup( label = "Model", - // The file, not the whole key: the repository is the - // same for every quantisation of a model, so the file - // name is what tells two of them apart. + // The file, not the whole key: the repository is the same for every + // quantisation of a model, so the file name is what tells two of them apart. options = models.map { it.file }, selected = models.firstOrNull { it.key == modelKey }?.file, onSelect = { file -> modelKey = models.first { it.file == file }.key }, @@ -280,13 +270,9 @@ fun SpawnScreen( withContext(Dispatchers.IO) { spawnSession( settings, - // The id, not the label: labels are - // editable and the server resolves by - // id. - // Non-null here: `chosen` came from - // `setup`'s own provider list, so - // reaching this point proves there was - // a setup to take it from. + // The id, not the label: labels are editable and the server + // resolves by id. Non-null here, since `chosen` came from + // `setup`'s own provider list. setup = setup.id, provider = chosen.name, title = title.trim(), @@ -294,9 +280,8 @@ fun SpawnScreen( if (isLlama) modelKey else model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude }, - // Sent only when set, so blank means - // "whatever llama.cpp does by default" - // rather than a zero. + // Sent only when set, so blank means "whatever llama.cpp does + // by default" rather than a zero. params = buildMap { if (isLlama) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt index 23c49c3..ecb75bf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt @@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L * One server-sent-events connection, framed. * * The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank - * line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no - * payload or a payload with no name. Two screens follow two different streams — a session's - * transcript and what a machine's import list is doing — and neither should be re-deriving that. + * line ends the frame, comments start with `:`, and a frame is either named with no payload or a + * payload with no name. Two screens follow two different streams and neither should re-derive that. * * Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the - * cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns - * rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting - * belongs to the caller, which is the only one that knows where to resume from. + * cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns + * rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where + * to resume from. */ class Sse(private val settings: ServerSettings) { @Volatile private var connection: HttpURLConnection? = null @@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) { /** * Follows the stream at [path], handing each frame to [onFrame] as its name (null for an * ordinary data frame) and its payload. The path is given here rather than at construction - * because a caller that reconnects usually resumes from somewhere new -- a cursor it has - * advanced past -- and that lives in the query string. + * because a caller that reconnects usually resumes from somewhere new. * * [onOpen] fires once the server has accepted the connection. That is the measured moment the * stream is live, and the only honest thing to clear a previous failure on: clearing on the - * first *event* instead left an idle stream displaying a connection error it had already - * recovered from, indefinitely. + * first *event* instead left an idle stream displaying an error it had already recovered from. */ fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { // Opening is inside the try, not before it. Everything this method can fail at owes the - // caller the same kind of failure -- both callers retry an [ApiException] and let anything - // else reach the top of the app -- and a connection that could not even be constructed - // used to escape as a raw `IOException` from a line no `catch` covered. + // caller the same kind of failure, and a connection that could not even be constructed used + // to escape as a raw `IOException` from a line no `catch` covered. var connection: HttpURLConnection? = null try { connection = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 66c1e8f..c2d1e11 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -44,16 +44,14 @@ private object Mocha { * * Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link* * -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike - * is a preference, not a contract, and the moment one wants a different accent the shared version - * becomes a thing to fight rather than a thing to use. + * is a preference, not a contract. * * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, - * Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the - * page is Base, a component's outlined card stays Base beside it, and a project's card is Surface - * 0: one visible step up, which is the whole of what the nesting has to say. + * Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside + * it, and a project's card is Surface 0: one visible step up, which is the whole of what the + * nesting has to say. * - * Accents on this palette are light, so anything filled with one takes Crust for its text rather - * than the near-white the roles default to. + * Accents on this palette are light, so anything filled with one takes Crust for its text. */ val AiAppColors = darkColorScheme( @@ -94,10 +92,8 @@ val AiAppColors = * What a session is doing, said in colour. * * Here rather than beside each screen that shows a status. These were separate literals in two - * other files -- an amber, a green and a red picked off Material's defaults -- so the same state - * was a slightly different colour depending which screen you looked at, and none of them belonged - * to this palette at all. A colour that carries meaning is part of the scheme, not a value typed - * where it happened to be needed. + * other files, so the same state was a slightly different colour depending which screen you looked + * at. A colour that carries meaning is part of the scheme, not a value typed where it was needed. */ val runningColor: Color @Composable get() = Mocha.Green @@ -107,7 +103,7 @@ val runningColor: Color * * The scheme's error colour, and deliberately not "the same red as a destructive button" even * though it is the same red. They are the same red for different reasons, and a state is not an - * action -- nothing here is a button. + * action. */ val failedColor: Color @Composable get() = MaterialTheme.colorScheme.error @@ -116,10 +112,9 @@ val failedColor: Color * About the session rather than about the task: a command, and the compaction one of them starts. * * Its own colour because it is its own kind of work. Everything else a session does is progress - * through what was asked of it; this is the session acting on itself -- rewriting what it - * remembers, taking a new name -- and none of it appears in the transcript as an answer to - * anything. A reader who has learned that blue means "not stuck, but not replying to you either" - * has learned the thing that distinguishes it from a session that has hung. + * through what was asked of it; this is the session acting on itself, and none of it appears in the + * transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not + * replying to you either" has learned what distinguishes it from a session that has hung. */ val commandColor: Color @Composable get() = Mocha.Blue @@ -128,10 +123,9 @@ val commandColor: Color * A clear: the conversation taken out of what the session is given. * * Red because of what it does, not because anything went wrong -- somebody asked for this, and a - * deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor] - * for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a - * button, it is the mark left where something was taken away. The reader never has to tell the - * three apart, because no two of them can appear as the same kind of thing. + * deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a + * third reason: this is neither a fault nor a button, it is the mark left where something was taken + * away. No two of the three can appear as the same kind of thing. */ val clearedColor: Color @Composable get() = Mocha.Red @@ -148,9 +142,9 @@ val warningColor: Color * The fill of a progress bar that is only reporting how far along something is. * * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary - * made it the loudest thing on a screen the reader opened to do something else. A download, or a - * compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and - * that one is [quotaColor]. + * made it the loudest thing on a screen the reader opened to do something else. A download has no + * limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is + * [quotaColor]. */ val progressColor: Color @Composable get() = Mocha.Blue @@ -158,15 +152,13 @@ val progressColor: Color /** * The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red. * - * One function rather than the same `when` written beside each bar, because the whole point of - * colouring by consequence is that the reader learns the step once -- two bars showing the same 80% - * in different colours teaches nothing except that the colour cannot be trusted. It reads as a - * difference in degree, which is all colour can carry: the states that differ in *kind* from this - * -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere, - * because a reader has no way to tell those from an ordinary low number by colour alone. + * One function rather than the same `when` written beside each bar, because the point of colouring + * by consequence is that the reader learns the step once. It reads as a difference in degree, which + * is all colour can carry: the states that differ in *kind* -- a window nobody could read, a + * machine that meters nothing -- are said in words elsewhere. * * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent - * without each converting it first and one of them getting it wrong by a factor of a hundred. + * without one of them getting it wrong by a factor of a hundred. */ @Composable fun quotaColor(percent: Double): Color = @@ -185,13 +177,11 @@ private const val OVER_LIMIT_PERCENT = 90.0 /** * The surface verbatim text sits on: a command, a tool's output, a code block in a reply. * - * The darkest value in the palette rather than a step up from the page, and that is the whole point - * -- everything else on this screen is somebody's prose, and this is what a machine was handed and + * The darkest value in the palette rather than a step up from the page, and that is the point -- + * everything else on this screen is somebody's prose, and this is what a machine was handed and * what it said back, character for character. Crust sits *below* Base, so the same colour reads as - * one clear step down both on the page, where a reply is drawn, and on a card, where a tool call - * is; a tint chosen upwards has to be picked twice and still collides with the card it lands on. - * The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill - * -- so a code block inside a tool call had no background at all. + * one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice + * and still collides with the card it lands on. * * One colour for all three, so "this is verbatim" is learnable once. */ @@ -202,11 +192,10 @@ val rawSurface: Color * Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette]. * * Here with the rest of the palette rather than beside the code that highlights: the colours a - * fence is drawn in are the same accents every other coloured thing in the app already uses, and - * splitting them out would make code the one surface whose palette came from somewhere else. + * fence is drawn in are the same accents every other coloured thing already uses. * - * Not a composable, because [highlight] runs off the drawing thread; these colours never vary with - * the theme. + * Not a composable, because [highlight] runs off the drawing thread; these never vary with the + * theme. */ fun catppuccinSyntax(): SyntaxPalette = SyntaxPalette( @@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette = * already made for every other blue on the screen. * * Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which - * is upstream's choice and not an omission here. A program that uses bright red to mean something - * other than red is relying on a distinction its own terminal may not draw either. + * is upstream's choice and not an omission here. * * The background is [rawSurface] because that is what a tool's output is drawn on, and reverse * video needs to know what it is reversing against. @@ -263,14 +251,12 @@ fun ansiPalette(): AnsiPalette = * * The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app * draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a - * code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so - * selecting a line of code looks like nothing happened even though the selection is there and - * copies correctly. + * code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of + * code looks like nothing happened even though it copies correctly. * - * Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that - * means something must carry its own contrast instead of borrowing it from the surface it happens - * to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill - * starts competing with the syntax colours it sits behind, which are the thing being read. + * Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only + * as far as it takes to read on the darkest of them -- past this the fill starts competing with the + * syntax colours it sits behind. */ val AiAppSelectionColors = TextSelectionColors( @@ -288,11 +274,10 @@ val linkColor: Color * A list's markers: the bullets and numbers down its left edge. * * The scheme's secondary accent rather than the text colour, because a marker is structure rather - * than words: coloured, the items of a list can be counted without reading them, and a nested list - * reads as a shape before it reads as text. Lavender is not one of the colours that mean something - * here -- green, red, peach and yellow are states and actions -- and it is the same at every depth, - * since depth is said by the glyph and the indent; a colour per depth would make a difference in - * degree look like one in kind. + * than words: coloured, the items of a list can be counted without reading them. Lavender is not + * one of the colours that mean something here, and it is the same at every depth, since depth is + * said by the glyph and the indent -- a colour per depth would make a difference in degree look + * like one in kind. */ val listMarkerColor: Color @Composable get() = Mocha.Lavender @@ -305,11 +290,9 @@ val overLimitColor: Color * The composer's buttons, coloured by what pressing one does rather than by where it sits. * * Green makes something happen now, blue makes it happen later, orange takes back what is in - * flight, red ends the process. The near-collisions with the states above are deliberate and worth - * naming rather than collapsing: [runningColor] is green because a session is working, - * [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session - * is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell - * them apart, because nothing here is a state and nothing there is pressable. + * flight, red ends the process. The near-collisions with the states above are deliberate: those are + * *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is + * a state and nothing there is pressable. */ val sendColor: Color @Composable get() = Mocha.Green @@ -322,9 +305,8 @@ val queueColor: Color * Interrupting the running turn: the work stops and the session stays. * * Orange rather than red because of how much it takes: only what is in flight. The process is still - * there holding the conversation, and the next message starts a turn as though nothing had - * happened. Red is spent on [stopColor], which is the same button in the same place when what it - * would end is the session's process. + * there holding the conversation. Red is spent on [stopColor], which is the same button in the same + * place when what it would end is the session's process. */ val pauseColor: Color @Composable get() = Mocha.Peach @@ -347,8 +329,7 @@ val startColor: Color * * The content colour is stated here beside the fill rather than inherited. A semantic colour has to * carry its own contrast: these fills are fixed whatever the surface under them does, so the theme - * will not change to rescue a foreground that stops being readable on one of them. Crust is what - * every accent on this palette takes, which is the same reason `onPrimary` is Crust above. + * will not change to rescue a foreground that stops being readable on one of them. */ @Composable fun actionButtonColors(fill: Color): ButtonColors = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 2f305e9..029b139 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -16,11 +16,10 @@ import org.json.JSONObject /** * A tool call's input, read rather than dumped. * - * Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"…", - * "timeout":120000}` themselves to find the one line they care about. So the fields that carry the - * meaning are pulled out -- the command a shell will run, what it is for, how long it may take -- - * and anything left over is still shown, because dropping a field would be claiming the tool has no - * other input when it might. + * Every tool's input arrives as JSON, and showing it raw makes the reader parse + * `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields + * that carry the meaning are pulled out, and anything left over is still shown, because dropping a + * field would be claiming the tool has no other input when it might. */ data class ToolInput( /** The thing that will actually be run or read, if this tool has one. */ @@ -30,8 +29,8 @@ data class ToolInput( /** The tool's own one-line summary, when it wrote one. */ val description: String?, /** - * How long the call may take, in the largest units it fits ([formatMillis]). Shown apart - * because it is a limit on the call rather than part of what the call does. + * How long the call may take, in the largest units it fits. Shown apart because it is a limit + * on the call rather than part of what the call does. */ val timeout: String?, /** Everything else, as `name: value` lines. Never dropped. */ @@ -47,7 +46,7 @@ data class ToolInput( * * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them * from being the special case that gets its own code path. Unknown tools fall through to "no - * subject, everything is rest", which is what the card always did. + * subject, everything is rest". */ private val SUBJECTS: Map> = mapOf( @@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput { try { JSONObject(input) } catch (_: org.json.JSONException) { - // Not an object: older transcripts and some tools send a bare - // string. It is still the input, so it is still shown. + // Not an object: older transcripts and some tools send a bare string. It is still the + // input, so it is still shown. return ToolInput( null, null, @@ -100,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput { /** * A tool call's input: its subject highlighted, then whatever else it carried. * - * On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing - * at all when the call carried neither, rather than as an empty block: a tinted rectangle with - * nothing in it is a rendering fault, and it is the shape a tool with no input actually has. + * On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the + * call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a + * rendering fault. * * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. @@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { if (parsed.subject == null && parsed.rest.isEmpty()) return RawBlock(modifier) { parsed.subject?.let { subject -> - // Not wrapped: a wrapped command hides where its arguments end, - // and the long one is the one being read closely. + // Not wrapped: a wrapped command hides where its arguments end, and the long one is the + // one being read closely. Text( - // Not cached: a tool's subject is one command line, which lexes in microseconds - // -- the cache exists for a fence with two hundred lines in it. + // Not cached: a tool's subject is one command line, which lexes in microseconds -- + // the cache exists for a fence with two hundred lines in it. remember(subject, parsed.language) { highlight(subject, parsed.language) }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 3ef2ddc..6fc28c4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -42,14 +42,11 @@ import androidx.compose.ui.unit.dp * the transcript's own order is what paging and the event stream depend on, and one screen's idea * of "these belong together" must not reach back into it. * - * Immutable, and said so, because Compose cannot tell. - * - * A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing - * the same events are equal. Compose infers stability from a class's fields, and a `List` field -- - * which several of these carry -- makes it assume the worst, so every composable taking one - * recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded - * rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll, - * and that is what a page landing costs on top of the fetch itself. + * Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather + * than edited, and two rows describing the same events are equal. Compose infers stability from a + * class's fields, and a `List` field -- which several of these carry -- makes it assume the worst, + * so a page of history landing recomposed all 148 loaded rows including the markdown inside them, + * measured as 701 compositions for 148 rows in one scroll. * * The promise this makes is real and has to stay true: nothing here is mutated after it is built. */ @@ -59,16 +56,12 @@ sealed class TranscriptRow { * This row's identity in the list, which must survive everything that can happen to the row. * * The list is keyed by this so that inserting a new message at one end, or a page of history at - * the other, moves the rows and not the reader. That makes it the load-bearing value on this - * screen: when a key changes, the list loses its anchor and the transcript steps under whoever - * is reading it. + * the other, moves the rows and not the reader. When a key changes, the list loses its anchor + * and the transcript steps under whoever is reading it. * * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, - * and it is the *same* value whether the run is drawn as one card or as a group. A lone call - * that gains a neighbour becomes a group without changing identity, which is the case a - * seq-based key got wrong: the row the reader was looking at was replaced rather than updated. - * Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row - * is one item and the item is what knows what it is called. + * and it is the *same* value whether the run is drawn as one card or as a group. Which value + * that is belongs to the item ([TranscriptItem.key]), not to a `when` here. */ abstract val key: Any @@ -76,12 +69,9 @@ sealed class TranscriptRow { * Where this row starts in the transcript: the sequence number of the oldest event behind it. * * Separate from [key], and deliberately so. [key] is the list's identity and is a display - * decision -- a tool row is named after its run, and a run takes its name from whichever call - * was first when it was folded, which changes as pages arrive. A seq is the server's own - * numbering: it is assigned once, never moves, and means the same thing to every device. So - * anything that has to point at a place in the conversation and still find it later -- a saved - * scroll position is the one -- points with this, and anything that has to identify a row - * within one composition uses [key]. + * decision; a seq is the server's own numbering, assigned once and meaning the same thing to + * every device. So anything that has to point at a place in the conversation and still find it + * later -- a saved scroll position -- points with this. */ abstract val startSeq: Long @@ -133,8 +123,7 @@ private fun groupRuns(items: List): List { // Grouped by the run each call says it belongs to, not by adjacency worked out here. // Adjacency is the same answer most of the time and a worse one at the edges: a call // arriving next to an existing run, or a page of history arriving in front of one, both - // change which call is *first*, and a group named after its first member is a different - // group every time that happens. + // change which call is *first*. if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { run += item } else { @@ -152,18 +141,14 @@ private fun groupRuns(items: List): List { * What says the calls belong together is the surface behind them, which is the one cue rather than * two half-cues -- rounded to the same corner every other card in the app has, so a group reads as * one object rather than as a square patch behind round things. The calls sit on it inset by - * [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows - * they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a - * notch. + * [GROUP_INSET], which is the container's own padding rather than an indent. * * Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so - * the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without - * separating them. See [connectedShape]. + * the run reads as one thing broken into its parts; see [connectedShape]. * * It closes from either end. A long group's header scrolls off while its last call is still on - * screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The - * bar at the foot is the same height as the heading at the top, so the surface the calls sit on is - * as thick below them as above. + * screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the + * same height as the heading at the top. */ @Composable fun ToolGroup( @@ -171,8 +156,7 @@ fun ToolGroup( expanded: Boolean, /** * Where it was pressed is the row's business rather than the control's -- a group has a control - * at each end, and only the row knows where its own ends are, so the row records the touch - * itself and this just says that one happened. + * at each end, and only the row knows where its own ends are. */ onToggle: () -> Unit, isToolExpanded: (String) -> Boolean, @@ -222,8 +206,8 @@ fun ToolGroup( ) } } - // Shutting it from here anchors the other end: the reader is at the bottom of a long - // group, and what they are looking at is what follows it. + // Shutting it from here anchors the other end: the reader is at the bottom of a long group, + // and what they are looking at is what follows it. CollapseBar(barHeight, onToggle) } } @@ -232,8 +216,8 @@ fun ToolGroup( * The height of a group's heading, and so of the bar at its foot. * * Derived from the type the heading is set in rather than written down, because the two have to - * match and a pair of numbers chosen to look equal stops being equal the moment either the style or - * the density changes. Taking the line height also means the heading cannot be clipped by it. + * match and a pair of numbers chosen to look equal stops being equal the moment the density + * changes. */ @Composable private fun groupBarHeight(): Dp { @@ -242,10 +226,9 @@ private fun groupBarHeight(): Dp { } /** - * The bottom half of a group's toggle: an arrow back up to its heading. - * - * Given the heading's height rather than padded to something that looks close, so the surface the - * calls sit on is the same thickness at both ends. See [groupBarHeight]. + * The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height + * rather than padded to something that looks close, so the surface the calls sit on is the same + * thickness at both ends. */ @Composable private fun CollapseBar(height: Dp, onToggle: () -> Unit) { @@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) { * does not. * * Written once and given an index rather than branched at each end, because a stack has three cases - * that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong - * when a run turns out to have three calls in it. + * that are one rule -- and the middle one is what a hand-written first/last pair gets wrong. */ @Composable private fun connectedShape(index: Int, count: Int): CornerBasedShape { @@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp * One tool call. * * Closed, it is a single line: the tool's name and what the call is for. The command itself is not - * on it, because a wrapped command turns one row into four and a run of them into a wall -- and the - * name plus the intent is what somebody scanning the transcript is reading for. + * on it, because a wrapped command turns one row into four and a run of them into a wall. * * Open, it shows the command, whatever else the input carried, and the output. The timeout sits at - * the top right: it is a limit on the call rather than part of what the call does, and it is worth - * seeing beside the command it constrains rather than buried in the fields below it. + * the top right: it is a limit on the call rather than part of what the call does. * * A call waiting on permission is shown open whatever the reader last chose, since the command is * the thing being decided and a row saying only "Bash" cannot be decided on. @@ -342,10 +322,9 @@ fun ToolCard( ) } ?: Spacer(Modifier.weight(1f)) } - // A spinner says the machine is working. While this call is waiting on an - // answer the machine is doing nothing at all -- the turn is stopped on the - // person reading it -- so it says whose move it is instead, in the colour this - // app uses everywhere for that. + // A spinner says the machine is working. While this call is waiting on an answer + // the machine is doing nothing at all -- the turn is stopped on the person reading + // it -- so it says whose move it is instead. if (deciding) { Spacer(Modifier.width(8.dp)) Text( @@ -370,9 +349,9 @@ fun ToolCard( modifier = Modifier.padding(top = 4.dp), ) } - // Everything AskUserQuestion carries is the questions, and those are drawn - // below as something answerable; dumping the same JSON above them would be the - // decision stated twice, once unreadably. + // Everything AskUserQuestion carries is the questions, and those are drawn below as + // something answerable; dumping the same JSON above them would be the decision + // stated twice, once unreadably. if (tool.tool != ASK_USER_QUESTION) { ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) } @@ -381,14 +360,12 @@ fun ToolCard( Text("Output", style = MaterialTheme.typography.labelSmall) // What the tool printed, on the surface everything verbatim gets and in the // face it was written for: this is column-aligned far more often than it is - // prose -- a directory listing, a diff, a table of numbers -- and a - // proportional font silently destroys the alignment that carried the meaning. + // prose, and a proportional font silently destroys the alignment that carried + // the meaning. // - // Its terminal styling applied and the rest of the escapes taken out, since - // what a shell prints is written for a terminal: colour is often the whole of - // what a diff or a test run is saying, and the sequences that carry it are - // unreadable drawn verbatim. Remembered against the text, so a card that is - // open through a scroll parses once. See [ansiStyled]. + // Its terminal styling applied and the rest of the escapes taken out: colour is + // often the whole of what a diff or a test run is saying. Remembered against + // the text, so a card that is open through a scroll parses once. val palette = remember { ansiPalette() } val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } RawBlock(Modifier.padding(top = 2.dp)) { @@ -400,10 +377,8 @@ fun ToolCard( } } } - // Shown open or closed. A call that produced a picture is one - // whose result *is* the picture, and a row that hides it says - // less than the one line it replaced -- unlike a command, which - // is what the closed line already summarises. + // Shown open or closed. A call that produced a picture is one whose result *is* the + // picture, and a row that hides it says less than the one line it replaced. tool.images.forEach { ref -> image(ref) } if (tool.asks.isNotEmpty()) { if (tool.tool == ASK_USER_QUESTION) { @@ -428,11 +403,10 @@ private fun PermissionAsk( onAnswer: (List, onSettled: () -> Unit) -> Unit, ) { // What was pressed, before the answer has been round-tripped. Two bare words with no submit - // step -- unlike a question card, where the answer is several choices and worth reviewing -- - // so the press has to be its own acknowledgement or the row sits unchanged for a round trip - // and reads as having missed the tap. Cleared when the request settles: by then either the - // answer is in `ask.answers` and the mark stands on a measurement, or it failed and the - // buttons come back rather than leaving a decision marked that nothing recorded. + // step -- unlike a question card, where the answer is worth reviewing -- so the press has to be + // its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request + // settles: by then either the answer is in `ask.answers`, or it failed and the buttons come + // back. var pressed by remember(ask.id) { mutableStateOf(null) } Spacer(Modifier.height(8.dp)) Text( @@ -442,8 +416,7 @@ private fun PermissionAsk( ) // Answered or not, the options stay and the one that was taken is marked -- see // [AskedQuestion], which is the same rule on the question card. A permission is where it - // matters most: "Answered: Deny" alone does not say that Allow was the alternative, and - // whether a tool was allowed or refused is the thing a reader comes back to this row for. + // matters most: "Answered: Deny" alone does not say that Allow was the alternative. val settled = ask.answers.isNotEmpty() AnswerOptions( ask.options, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt index d985416..606628e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt @@ -11,30 +11,25 @@ import java.io.RandomAccessFile * This phone's copy of the transcripts it has already been sent, so reopening a session does not * download it again. * - * What is stored is the server's own JSON for one event per line, in transcript order -- the - * elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means - * running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one - * cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown]) - * keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not* - * what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a - * cache of rows would need throwing away on every app update that touched `foldEvent`. + * What is stored is the server's own JSON for one event per line, in transcript order. Reading the + * cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a + * fetched one cannot draw differently, and an event type this build does not know keeps every field + * it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a + * rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`. * * See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it: * 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the * cache is a copy and is never inferred, folded or edited here; * 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache; * 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a - * cold open, never to a blank or a wrong screen; - * 4. a line already on the phone is not fetched again. + * cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched + * again. * * A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs - * under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a - * line is the sequence number and whether the line is a streamed delta, and both are read with a - * regex over text the server wrote. A line it cannot read that way is treated as damage, which - * gives the same answer as having no cache at all. - * - * [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that - * throws under the JVM tests, and this file has to be exercisable there. + * under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line + * is the sequence number and whether the line is a streamed delta, both read with a regex. A line + * it cannot read that way is treated as damage. [warn] is where failures are said for the same + * reason. */ class TranscriptCache( private val root: File, @@ -44,11 +39,9 @@ class TranscriptCache( fun session(id: String): SessionCache = SessionCache(File(root, id), warn) /** - * Deletes every session directory not in [ids], called after a successful list fetch. - * - * The path out for a session deleted on another device or at the backend: nothing here would - * otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is - * megabytes. + * Deletes every session directory not in [ids], called after a successful list fetch. The path + * out for a session deleted on another device: nothing here would otherwise hear about it, and + * unlike a draft's few bytes what it leaves behind is megabytes. */ fun retainOnly(ids: Set) = guardIo(Unit, warn) { @@ -57,11 +50,9 @@ class TranscriptCache( /** * Deletes least-recently-touched session directories, never [keep], until the whole of this - * server's cache is under [budget]. - * - * Least-recently-touched rather than largest: what a reader is likely to open again is what - * they opened last, and evicting the big ones first would empty the cache for exactly the - * conversations it exists for. + * server's cache is under [budget]. Least-recently-touched rather than largest: what a reader + * is likely to open again is what they opened last, and evicting the big ones first would empty + * the cache for exactly the conversations it exists for. */ fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) = guardIo(Unit, warn) { @@ -81,18 +72,16 @@ class TranscriptCache( } /** - * How much of this phone's cache directory all of one server's transcripts may take. - * - * A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small - * fraction of a phone. A number to revisit against real use rather than a measurement of anything. + * How much of this phone's cache directory all of one server's transcripts may take. A dozen of the + * largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone. + * A number to revisit against real use rather than a measurement of anything. */ const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000 /** - * What the newest cached line says, which is what the probe checks against the server. - * - * Both halves are wanted together and by the same caller: the seq is what the request asks about, - * and the line is what its answer is compared with. + * What the newest cached line says, which is what the probe checks against the server. Both halves + * are wanted together: the seq is what the request asks about, and the line is what its answer is + * compared with. */ data class CachedTail(val seq: Long, val line: String) @@ -101,35 +90,31 @@ data class CachedTail(val seq: Long, val line: String) * * A chunk is a set of lines *and a claim about what they cover*, and the two are not the same * thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the - * run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to - * the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open - * range in the file's name: + * run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the + * 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range + * in the file's name: * ``` - * -.rows.jsonl a coalesced page; end is the `before` it was fetched with - * -.raw.jsonl an uncoalesced page, or a closed live run - * -open.raw.jsonl the live run; end is its last line's seq + 1 + * -.rows.jsonl a coalesced page; end is the `before` it was fetched with + * -.raw.jsonl an uncoalesced page, or a closed live run -open.raw.jsonl the + * live run; end is its last line's seq + 1 * ``` * - * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of - * adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a - * gap are kept, because the gap is usually closed by paging back through it, but nothing is served - * across one. + * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending + * at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because + * the gap is usually closed by paging back through it, but nothing is served across one. * * **The newest chunk is always raw**, which is what makes the stream cursor and the probe well - * defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces - * the newest window. It holds by construction (the opening window and every stream frame are raw) - * and is checked on read: a `.rows` chunk at the newest end can only mean this app died between - * closing one live run and opening the next, and it discards the session. + * defined. It holds by construction (the opening window and every stream frame are raw) and is + * checked on read: a `.rows` chunk at the newest end can only mean this app died between closing + * one live run and opening the next, and it discards the session. * * Nothing here is load-bearing. Every operation that touches the disk answers as though the cache * were empty when it cannot, and a write failure disables writing for the rest of this instance's * life so that a full disk costs one log line rather than one per delta. * * Every operation is synchronized, because two of them really do run at once: the stream appends - * live events from its own IO thread while a reader scrolling back reads pages from another. The - * lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end - * and its writer are never read half-rotated -- which would show up as a page silently fetched - * again, or as a stored chunk overlapping the run it was written beside. + * live events from its own IO thread while a reader scrolling back reads pages from another. What + * it buys is that the open chunk's name, its end and its writer are never read half-rotated. */ class SessionCache( private val dir: File, @@ -141,9 +126,8 @@ class SessionCache( * The open chunk's writer, its file, and the seq that chunk now ends at. * * Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of - * times a second while a reply streams -- a syscall each is the thing to avoid. What that costs - * is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never - * a wrong one. + * times a second while a reply streams. What that costs is the unflushed tail on a crash, which + * is safe: a shorter cache is a longer catch-up, never a wrong one. */ private var writer: BufferedWriter? = null private var openFile: File? = null @@ -153,8 +137,7 @@ class SessionCache( * The newest line of the suffix, or null when there is none or the newest chunk is not raw. * * This is the cursor the live stream would resume from, so it is also what has to be shown to - * still be the server's own line before anything is resumed from it -- see - * `TranscriptSource.probe`. + * still be the server's own line before anything is resumed from it. */ @Synchronized fun tail(): CachedTail? = @@ -187,30 +170,26 @@ class SessionCache( * The page of lines before [before], oldest first, or null when the cache cannot answer. * * Null is a miss -- the suffix does not cover the ground immediately below [before] -- and - * means the server has to be asked. It is deliberately not an empty list: an empty page is how - * the screen is told it has reached the start of the conversation, and a cache saying that of + * means the server has to be asked. Deliberately not an empty list: an empty page is how the + * screen is told it has reached the start of the conversation, and a cache saying that of * history it merely does not hold would stop the transcript scrolling back for good. * * [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open - * leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of - * it -- so a cache that could only answer at a boundary would send the very first backwards - * page to the server and, since that page would overlap the run, keep none of it. + * leaves behind is in the middle of the live run, so a cache that could only answer at a + * boundary would send the very first backwards page to the server and, since that page would + * overlap the run, keep none of it. * - * A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns: - * the caller already treats a short page as a page. - * - * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`: - * every event that is not a streamed delta is a row, and each maximal run of deltas is one row. + * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`. * The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of - * its first delta either way, so anchors and the next `before` land where they do today. + * its first delta either way. */ @Synchronized fun page(before: Long, limit: Int, rows: Boolean): List? = guard(null) { val suffix = suffix() val newest = suffix.lastOrNull() ?: return@guard null - // Above what is held, or at or below where it starts: either way the run the caller - // is scrolling into is not continuous with this one, and only the server has it. + // Above what is held, or at or below where it starts: either way the run the caller is + // scrolling into is not continuous with this one, and only the server has it. if (before > newest.end || before <= suffix.first().first) return@guard null val taken = ArrayDeque() var counted = 0 @@ -220,14 +199,14 @@ class SessionCache( if (!wanting) break if (chunk.first >= before) continue eachLine(chunk) { line -> - // The page is what is *before* the cursor; the rows at or above it are the - // ones already on screen. + // The page is what is *before* the cursor; the rows at or above it are already + // on screen. if (seqOf(line)!! >= before) return@eachLine true if (rows) { val delta = isDelta(line) - // Stop only between rows: a delta continuing the run being gathered is - // part of a row already counted, and breaking on it would drop the half - // of that row already taken. + // Stop only between rows: a delta continuing the run being gathered is part + // of a row already counted, and breaking on it would drop the half of that + // row already taken. if (counted >= limit && !(delta && inRun)) wanting = false else { if (!delta || !inRun) counted++ @@ -248,8 +227,7 @@ class SessionCache( * asked with so that it stops where this phone's copy starts. Null when there is no such chunk. * * Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that - * the gap is closed with exactly the bytes it is wide and the history behind it is served - * locally from then on. + * the gap is closed with exactly the bytes it is wide. */ @Synchronized fun coveredUpTo(before: Long): Long? = @@ -260,9 +238,8 @@ class SessionCache( * * Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced * event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from - * arising by bounding what it fetches, and this is the guard for a page that arrives anyway -- - * from a server without the `after` parameter, say. Such a page is still drawn; it is only not - * kept. + * arising by bounding what it fetches, and this is the guard for a page that arrives anyway. + * Such a page is still drawn; it is only not kept. * * The newest chunk is never stored through here: the opening window and every live frame go * through [append], which is what keeps the newest chunk raw and open. @@ -282,18 +259,17 @@ class SessionCache( * Appends one live event, which is also how a freshly fetched opening window is stored. * * A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a - * `reset` looks like from here -- and closes the open chunk under the end it turned out to have - * before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE - * contract is `seq > after`, so that is a guard rather than a path. + * `reset` looks like from here -- and closes the open chunk under the end it turned out to + * have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`. */ @Synchronized fun append(line: String, seq: Long) = guard(Unit) { if (disabled) return@guard val writer = writerFor(seq) ?: return@guard - // Written as it arrived. A newline inside it would split one event into two - // unreadable halves, but neither source can produce one: SSE framing forbids it, and - // a page's elements are re-serialized compactly, which escapes it. + // Written as it arrived. A newline inside it would split one event into two unreadable + // halves, but neither source can produce one: SSE framing forbids it, and a page's + // elements are re-serialized compactly, which escapes it. writer.write(line) writer.write("\n") openEnd = seq + 1 @@ -329,9 +305,7 @@ class SessionCache( /** * Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored. - * - * Recomputed per operation rather than kept: another operation may have changed the directory, - * and a hundred names is a directory listing. + * Recomputed per operation rather than kept: another operation may have changed the directory. */ private fun chunks(): List { writer?.flush() @@ -354,9 +328,9 @@ class SessionCache( * is the one writing it. * * An open chunk whose last line cannot be read is this app having died mid-write. That line is - * dropped and the file truncated to the last good one before anything is served from it, which - * is the one place damage is repaired rather than discarded: the tail of an append-only file is - * the only place a partial line can be. + * dropped and the file truncated to the last good one, which is the one place damage is + * repaired rather than discarded: the tail of an append-only file is the only place a partial + * line can be. */ private fun openEndOf(file: File, first: Long): Long { if (openFile == file && openEnd > 0) return openEnd @@ -373,8 +347,7 @@ class SessionCache( * The contiguous run of adjacent chunks ending at the newest one, oldest first. * * A newest chunk that is not raw cannot happen while this code is the only writer, and means - * the directory is not to be trusted -- so the session is discarded rather than served across - * whatever else is wrong with it. + * the directory is not to be trusted -- so the session is discarded. */ private fun suffix(): List { val all = chunks() @@ -393,10 +366,9 @@ class SessionCache( /** * Each line of [chunk], newest first, until [take] says stop. * - * Backwards and lazily, because every question this cache is asked is about the newest end -- - * the tail, the opening window, the page before a cursor -- and a live run grows to the size of - * the conversation. Reading the file whole to answer with eighty lines of it is the cost the - * server's own reader was rewritten to stop paying. + * Backwards and lazily, because every question this cache is asked is about the newest end and + * a live run grows to the size of the conversation. Reading the file whole to answer with + * eighty lines of it is the cost the server's own reader was rewritten to stop paying. * * Damage anywhere but at the tail of the open chunk was not written by this code, and there is * no honest way to say what a chunk covers with a line of it unreadable -- so it discards the @@ -433,8 +405,8 @@ class SessionCache( } rename(existing.file, existing.first, existing.end) } - // A chunk that was created and never written to would otherwise be left behind under a - // name a second one is about to want; it covers nothing, so nothing is lost with it. + // A chunk that was created and never written to would otherwise be left behind under a name + // a second one is about to want; it covers nothing, so nothing is lost with it. dir.listFiles().orEmpty().forEach { if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L) it.delete() @@ -480,12 +452,12 @@ class SessionCache( * * None of this is reported on screen: none of it changes what the screen shows -- every read * here has a network path beside it producing the same result -- and the reader has nothing to - * do about it. It is logged, and damage discards this session's cache, which is what makes the - * next open an ordinary cold one. + * do about it. Damage discards this session's cache, which makes the next open an ordinary cold + * one. */ private fun guard(ifBroken: T, body: () -> T): T = - // A disk that refused once will refuse again, once per delta, so the first refusal is - // also the last: this instance stops writing rather than logging a line a token. + // A disk that refused once will refuse again, once per delta, so the first refusal is also + // the last: this instance stops writing rather than logging a line a token. guardIo( ifBroken, warn, @@ -515,8 +487,7 @@ private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""") * One line's sequence number, or null when the line is not one of ours. * * A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM - * tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the - * event flattened after it), so the first match is the top-level one. + * tests: the seq is the first field the server writes, so the first match is the top-level one. */ private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull() @@ -536,11 +507,10 @@ private const val READ_BLOCK = 64 * 1024 * * Every question the cache is asked is about the newest end of a chunk, and a live run reaches the * size of the conversation, so reading forwards means reading a transcript to answer with the last - * eighty lines of it. This reads blocks from the end and stops where the caller stops. + * eighty lines of it. * * Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte - * UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file - * yields nothing, which is the same answer as an empty one. + * UTF-8 sequence; each line is decoded whole. A missing file yields nothing. */ private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) { if (!file.isFile) return @@ -581,8 +551,8 @@ private const val NEWLINE = '\n'.code.toByte() * Drops a final line that is not one of ours, by truncating the file to where it starts. * * This app having died mid-write is the one kind of damage that is repaired rather than discarded: - * the tail of an append-only file is the only place a partial line can be, and everything before it - * is intact. A second bad line is not this, and is left for the read path to notice. + * the tail of an append-only file is the only place a partial line can be. A second bad line is not + * this, and is left for the read path to notice. */ private fun repairTail(file: File) { var truncateTo = -1L @@ -598,9 +568,7 @@ private fun sizeOf(file: File): Long = /** * The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance. - * - * [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's - * cache, giving up on writing. + * [onFailure] is what the caller does about it beyond answering [ifBroken]. */ private fun guardIo( ifBroken: T, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index cb91df9..5089ca2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -9,10 +9,9 @@ import kotlinx.coroutines.withContext * * Events are the only data source, and there is deliberately no second shape for history to drift * from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are - * all the same events through the same parser. Since 2026-09-04 the cache is where most of them - * come from on a session opened again -- see [TranscriptCache], which stores the server's lines - * rather than these rows for exactly that reason: a row is a rendering, and its shape changes - * whenever this file does. + * all the same events through the same parser. [TranscriptCache] stores the server's lines rather + * than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever + * this file does. */ @Immutable sealed class TranscriptItem { @@ -24,10 +23,8 @@ sealed class TranscriptItem { * list is addressed by position: whatever somebody had scrolled to keeps its index while the * content underneath it slides, which reads as the view scrolling on its own. * - * A seq is the right identity because it is what the transcript itself is ordered by, it never - * changes, and it is already carried by every event. A row built from several events -- a - * streaming message, a tool call and its result -- keeps the seq of the first, so it holds - * still while the rest of it arrives. + * A row built from several events keeps the seq of the first, so it holds still while the rest + * of it arrives. */ abstract val seq: Long @@ -35,10 +32,8 @@ sealed class TranscriptItem { * This item's identity on screen, which is its [seq] for everything that has one of its own. * * Here rather than in [TranscriptRow.Single] because the two items that need something else are - * the two that know why: a tool call is named after its run, and a peer note is *sorted* by the - * turn it started rather than by where it arrived. Asking each item what it is called is also - * what stops the next such item being missed -- a `when` over concrete types in the row would - * have to gain a case, silently, and nothing says when it did not. + * the two that know why. Asking each item what it is called is also what stops the next such + * item being missed -- a `when` over concrete types would have to gain a case, silently. */ open val key: Any get() = seq @@ -59,15 +54,13 @@ sealed class TranscriptItem { * What it buys is the split. [transcriptUnits] keeps the newest reply whole because a * streaming reply's text changes per delta and splitting a changing text is a parse per * delta -- but "newest" outlives the turn, so a session that ends on a long reply was - * drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9 - * Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the - * framework's own bookkeeping, which grows with alive nodes. + * drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on + * screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping. * - * Folded from the status event that ended the turn, rather than read off the screen's + * Folded from the status event that ended the turn rather than read off the screen's * status, because rows only change through the held-events gate: the split changes the * newest row's list identity, and doing that from a status flip while somebody is reading - * inside that reply would step the list under them. An event has to wait for the reader to - * be at the newest end; a screen state does not. + * inside that reply would step the list under them. */ val settled: Boolean = false, ) : TranscriptItem() @@ -79,11 +72,10 @@ sealed class TranscriptItem { * The run of adjacent calls this one belongs to, named once when the call is folded in and * never recomputed. * - * Carried rather than derived because a run can gain members at *either* end -- a new call - * arriving beside it, or a page of history arriving in front of it -- so no function of its - * current members is stable. It is the first call's id at the moment the run started, which - * is a name rather than a description: [joinPages] hands it to older calls that turn out to - * belong to the same run, instead of renaming the run they joined. + * Carried rather than derived because a run can gain members at *either* end, so no + * function of its current members is stable. It is the first call's id at the moment the + * run started, which is a name rather than a description: [joinPages] hands it to older + * calls that turn out to belong to the same run. */ val runId: String, val tool: String, @@ -94,19 +86,16 @@ sealed class TranscriptItem { * The questions this call is waiting on, in the order they were asked. * * On the call's own row rather than beside it: an ask used to arrive as a second card - * repeating the input verbatim, so the reader saw the same command twice and had to work - * out that it was one event. The backend says which call a question is about, so this is a - * fact rather than a match on the input. + * repeating the input verbatim, so the reader saw the same command twice. The backend says + * which call a question is about, so this is a fact rather than a match on the input. * - * A list because AskUserQuestion asks up to four at once, and they are one decision to make - * -- a permission is the case of exactly one, not a different shape. + * A list because AskUserQuestion asks up to four at once, and a permission is the case of + * exactly one rather than a different shape. */ val asks: List = emptyList(), /** - * Images this call's result carried, drawn under it. - * - * Beside it they had to be paired by position, and position is the thing a page boundary - * breaks -- a screenshot loaded on one page and its call on the next read as unrelated. + * Images this call's result carried, drawn under it. Beside it they had to be paired by + * position, and position is what a page boundary breaks. */ val images: List = emptyList(), ) : TranscriptItem() { @@ -134,9 +123,8 @@ sealed class TranscriptItem { data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem() /** - * A message another agent sent this session. - * - * Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters. + * A message another agent sent this session. Its own row rather than a [UserMsg]: see + * [PeerMessageRow] for why the voice matters. */ data class PeerNote( override val seq: Long, @@ -145,11 +133,9 @@ sealed class TranscriptItem { /** * The seq of the event this note came in on, which is what makes it itself. * - * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at - * so the note is drawn above the reply it caused. Two messages that arrive during one turn - * therefore share a seq -- and sharing an identity as well killed the app, because the - * transcript list refuses two items with one key. Two agents writing to a session mid-turn - * is an ordinary afternoon, not a corner. + * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began + * at. Two messages that arrive during one turn therefore share a seq -- and sharing an + * identity as well killed the app, because the list refuses two items with one key. */ val arrived: Long = seq, ) : TranscriptItem() { @@ -158,10 +144,9 @@ sealed class TranscriptItem { } /** - * A command the session ran on itself -- `/compact`, `/rename`. - * - * Kept in the transcript rather than only shown while it waits, because it explains what - * follows: a conversation that suddenly has half the context, or a session with a new name. + * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather + * than only shown while it waits, because it explains what follows: a conversation that + * suddenly has half the context, or a session with a new name. */ data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() @@ -170,7 +155,6 @@ sealed class TranscriptItem { /** * A clear that happened: everything above it left the session's context and stayed on screen. - * * Carries only its position, because that is all it means. */ data class ClearedNote(override val seq: Long) : TranscriptItem() @@ -179,12 +163,12 @@ sealed class TranscriptItem { * A compaction that happened, and what it recovered. * * In the transcript rather than only in the status line, because the status is gone the moment - * it finishes and this is the part worth keeping: it is the explanation for a gap in the - * conversation, and for a minute or two in which the session was busy with nothing to show. + * it finishes and this is the part worth keeping: the explanation for a gap in the + * conversation. * - * The wire also says what triggered it, and this deliberately does not carry that: the row says - * the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would - * be a field nothing can read. + * The wire also says what triggered it, and this deliberately does not carry that -- the row + * says the two sizes and nothing else, so keeping the trigger would be a field nothing can + * read. */ data class CompactedNote( override val seq: Long, @@ -197,16 +181,13 @@ sealed class TranscriptItem { * The run a call joins: the one it lands next to, or a new one named after itself. * * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run - * keeps whatever it was called when it started, however many calls arrive at either end of it - * afterwards. + * keeps whatever it was called when it started, however many calls arrive at either end afterwards. * * A question to the reader is in a run of its own, which is what puts it on the transcript as a row - * rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is - * always visible, since a run of one is drawn as itself rather than as a group; and the calls - * around it fall into a group before it and a group after it, so where the reader was asked - * something is legible in the shape of the transcript without opening anything. It ends the run - * before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in - * the work, not a gap in the middle of one run. + * rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible, + * since a run of one is drawn as itself; and the calls around it fall into a group before it and a + * group after it, so where the reader was asked something is legible in the shape of the transcript + * without opening anything. */ private fun runIdFor(items: List, id: String, tool: String): String { val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id @@ -219,31 +200,23 @@ private fun runIdFor(items: List, id: String, tool: String): Str * boundary cut in two. * * Two things straddle a boundary: a tool call separated from its result, and a message separated - * from the rest of itself. Both were one thing before the transcript was cut into pages, and both - * have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn - * twice, arriving from the same cause. + * from the rest of itself. Both were one thing before the transcript was cut into pages. * * A boundary lands wherever it lands, and roughly half the time that is between a call and its * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable * from one that never happened. When the older page arrives it brings the real `ToolStart`, and - * concatenating the two lists left *both*: the same call twice, once as a proper card and once as a - * nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than - * the miscount -- the extra row is at the join, so it also moves everything the reader was looking - * at. + * concatenating the two lists left *both*: the same call twice. * * Merged by the call's own id rather than by position, because position is exactly what a page - * boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the - * newer on what an end knows (the output, and whether it finished), which is the only way round - * that loses nothing. + * boundary destroys. The older row wins on what a start knows and the newer on what an end knows, + * which is the only way round that loses nothing. * * The third thing is the *run*, and it is the one that used to be missed. Every page ends up here, * but [adoptRun] only ran on the path where a split call had been found -- so the boundary that - * falls cleanly between two finished calls, which is most of them, went straight to concatenation - * and left the older page's calls under the run name they were folded with. On screen: one run of - * tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two - * early returns were an optimisation on a list the size of one page, and they were skipping work - * rather than saving it. + * falls cleanly between two finished calls, which is most of them, left the older page's calls + * under the run name they were folded with. On screen: one run of tool calls drawn as two groups, + * with the seam wherever the reader happened to have paged. */ fun joinPages(earlier: List, later: List): List { val (older, newer) = healSplitMessage(earlier, later) @@ -276,15 +249,13 @@ fun joinPages(earlier: List, later: List): List< /** * Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * - * [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas - * accumulate into the message before them -- so two meeting at a join are always the two halves of - * one reply, and leaving them apart drew a single answer as two, with a paragraph break through the - * middle of a sentence. + * [foldEvent] never leaves two assistant messages next to each other inside one page, so two + * meeting at a join are always the two halves of one reply, and leaving them apart drew a single + * answer as two with a paragraph break through the middle of a sentence. * - * The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on - * screen, and renaming that is how the list loses its anchor. It grows by what the older half - * brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded, - * so the growth extends off the top of the screen, away from the row the list anchors to. + * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older + * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is + * loaded, so the growth extends off the top of the screen. */ private fun healSplitMessage( earlier: List, @@ -302,19 +273,18 @@ private fun healSplitMessage( * Hands the older calls at the join the name of the run they are joining. * * The two pages were folded separately, so a run split by the boundary came back as two runs with - * two names. Naming the joined run after the *older* half would be the obvious way round and is the - * wrong one: the newer half is the part already on screen, and renaming it is renaming the row the - * reader is looking at, which is how a list loses its anchor and steps under them. So the arriving - * calls take the name of the ones already there, and nothing visible changes identity. + * two names. Naming the joined run after the *older* half would be the obvious way round and is + * wrong: the newer half is the part already on screen, and renaming it is renaming the row the + * reader is looking at, which is how a list loses its anchor. */ private fun adoptRun( earlier: List, later: List, ): List { val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier - // A question is in a run of its own on both sides of the join, the same as it would be had - // the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a - // group straight through the row the reader was asked something on. + // A question is in a run of its own on both sides of the join, the same as it would be had the + // two pages been folded as one. Without this the heal would merge a group straight through the + // row the reader was asked something on. if (first.tool == ASK_USER_QUESTION) return earlier val joining = first.runId val tail = earlier.takeLastWhile { @@ -329,10 +299,8 @@ private fun adoptRun( * A peer message goes above the turn it started, not where it happened to arrive. * * The live Claude Code path cannot record it in place: the CLI says nothing about a peer message - * until the turn's `result`, so the event lands below the whole reply it caused -- the answer - * printed above the question. The server stamps it with where that turn began - * ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list - * where it belongs rather than being drawn out of order at the end. + * until the turn's `result`, so the event lands below the whole reply it caused. The server stamps + * it with where that turn began and the note takes that seq. * * Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and * paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as @@ -340,8 +308,7 @@ private fun adoptRun( * seq belongs to a status change and a status draws no row -- true, and it answered the wrong * question: what two notes stamped with the same turn collide with is each other. * - * Without a stamp -- a message replayed out of a session file, which is already in the right place - * -- it stays where it arrived. + * Without a stamp -- a message replayed out of a session file -- it stays where it arrived. */ private fun placePeerNote( items: List, @@ -360,15 +327,14 @@ private fun placePeerNote( * The calls the note now sits in front of, renamed if they were sharing a run with the calls behind * it. * - * A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about - * turns -- so a turn opening with a tool call, straight after one that ended with one, folds them - * into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the - * same name: two rows with one key, which a keyed list cannot draw at all. + * A run is named from what a call landed next to, and nothing there knows about turns -- so a turn + * opening with a tool call, straight after one that ended with one, folds them into a single run. + * Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows + * with one key, which a keyed list cannot draw at all. * * The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right - * for the opposite reason. There the two halves were always one run and the newer was already on - * screen; here they were never one turn's work, and both halves change appearance at the same - * moment the note appears between them. + * for the opposite reason: there the two halves were always one run, here they were never one + * turn's work. */ private fun splitRun(tail: List, behind: String?): List { val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail @@ -406,12 +372,10 @@ fun foldEvent(items: List, entry: SeqEvent): List updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> - // Created when its start is not here, rather than dropped. A - // fold that only ever *updates* loses the whole call when the - // start fell outside the loaded window, and a tool call that - // renders as nothing is indistinguishable from one that never - // happened. The name is unknown from an end alone; loading the - // page before this one replaces the row with the real thing. + // Created when its start is not here, rather than dropped. A fold that only ever + // *updates* loses the whole call when the start fell outside the loaded window, and a + // tool call that renders as nothing is indistinguishable from one that never happened. + // Loading the page before this one replaces the row with the real thing. if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { updateTool(items, event.id) { it.copy(output = event.output, done = true) } } else { @@ -419,9 +383,8 @@ fun foldEvent(items: List, entry: SeqEvent): List, entry: SeqEvent): List, entry: SeqEvent): List - // Resolved wherever it is drawn: a card of its own, or a tool - // row's ask. Missing the second left an Allow/Deny pair live on - // a question already answered from another device. + // Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the + // second left an Allow/Deny pair live on a question already answered from another + // device. items.map { when { it is TranscriptItem.QuestionCard && it.id == event.id -> @@ -475,20 +437,19 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.CommandRow(entry.seq, event.text) // Screen-level state, not transcript rows -- see SessionScreen. is SessionEvent.CommandQueued -> items - // No row of its own: a message that is still waiting is drawn as a pending bubble below - // the transcript, and becomes an ordinary one where the session read it. + // No row of its own: a message that is still waiting is drawn as a pending bubble below the + // transcript, and becomes an ordinary one where the session read it. is SessionEvent.MessageQueued -> items - // The bubble goes away and nothing takes its place: the message was never read, so there - // is nothing it belongs above. + // The bubble goes away and nothing takes its place: the message was never read, so there is + // nothing it belongs above. is SessionEvent.MessageDropped -> items is SessionEvent.Settings -> items is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Image -> - // Under the call that produced it when there is one, and a row of - // its own when there is not -- a person's own attachment belongs - // to no call, and neither does one whose call fell outside the - // loaded window. + // Under the call that produced it when there is one, and a row of its own when there is + // not -- a person's own attachment belongs to no call, and neither does one whose call + // fell outside the loaded window. if ( event.about != null && items.any { it is TranscriptItem.ToolRun && it.id == event.about } @@ -508,9 +469,8 @@ fun foldEvent(items: List, entry: SeqEvent): List, state: String): List { if (sessionWorking(state)) return items @@ -533,8 +493,7 @@ private fun updateTool( * The default dispatcher sizes itself to the machine, which is right for work somebody is waiting * on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and * taking every core for them leaves the thread that draws the frame queueing behind one -- measured - * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to - * *start* rather than taking too long once it had. + * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile. */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private val parsingThreads = Dispatchers.Default.limitedParallelism(2) @@ -543,37 +502,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2) * Parses the markdown among [rows], off whatever thread is drawing. * * Called where a page of transcript is folded rather than where a row is composed, which is the - * whole point: the work happens seconds before the reader reaches the rows it was done for. See - * [ParsedReplies]. + * whole point: the work happens seconds before the reader reaches the rows it was done for. * * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer - * message, every one of them whole, since every piece of a message is drawn from its one parse -- - * because a string warmed under a key no row ever looks up is a miss that nothing reports; see - * [transcriptUnits], which is the flatten this has to agree with. It reads the same + * message -- because a string warmed under a key no row ever looks up is a miss that nothing + * reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages - * hand it back through here, while the whole loaded transcript crosses this on every page. + * hand it back through here. * * Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer * message was missing: this used to filter for assistant replies alone, so the one row type nobody - * had thought about paid its whole parse in the frame it appeared in, with no counter saying which - * row it was. + * had thought about paid its whole parse in the frame it appeared in. */ suspend fun warm(replies: ParsedReplies, rows: List) { withContext(parsingThreads) { val texts = rows.flatMap { row -> when (row) { is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text } - // A message from another agent is markdown too, and it is the longest thing - // in a transcript often enough that leaving it out was the whole of why one - // cost a fifth of a second to open: it was the only markdown in the app - // parsed on the thread that draws. + // A message from another agent is markdown too, and it is the longest thing in a + // transcript often enough that leaving it out was the whole of why one cost a fifth + // of a second to open. is TranscriptItem.PeerNote -> listOf(row.text) else -> emptyList() } } if (texts.isNotEmpty()) replies.warm(texts) - // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's - // licence to draw these as blocks on the composing thread. + // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence + // to draw these as blocks on the composing thread. rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt index 35ec86a..6e271fa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -25,31 +25,24 @@ import androidx.compose.ui.unit.dp * zero is the newest content and sits at the bottom, so a message arriving extends the end the * viewport is pinned to and following it is not an effect -- and a page of older history lands at * indices past everything visible, which moves nothing on screen. The keyboard is the same case - * from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A - * conversation shorter than the screen stacks from the bottom, hanging from the composer. + * from the other side: the viewport shrinks and the anchored item stays against its bottom edge. * * The lazy list is also the whole of the windowing. Only what is near the viewport is composed and * alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the * property a plain column here had to approximate with retained ranges and stand-in spacers, each - * of which was a way to flicker. An item the framework composes is drawn the same frame it is - * placed, and an item off screen is not a node at all. + * of which was a way to flicker. * * What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a - * reply, and its parse is already made by [warm] before the fold that introduces it -- so entering - * composition costs laying out one paragraph, not parsing a message. + * reply, and its parse is already made by [warm] before the fold that introduces it. * - * The whole list sits in a [SelectionContainer], which is what makes every word in the transcript - * selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a - * transcript is one body of text to a reader, and a container per row would mean a selection could - * never cross from a reply into the tool output that follows it -- and would leave whatever was - * drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep - * their tap handlers: selection is a long press, and the container passes an ordinary click through - * to the card under it. + * The whole list sits in a [SelectionContainer], which is what makes every word selectable by the + * platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one + * body of text to a reader, and a container per row would mean a selection could never cross from a + * reply into the tool output that follows it -- and would leave whatever was drawn without one + * silently unselectable. Rows keep their tap handlers: selection is a long press. * * [selection] is the container's own state, held by the caller rather than made here, because the - * rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose - * job is to put a selection away is not also a tap on the card under it. See the caller's - * `expanding`. + * rows have to be able to ask whether anything is selected before they act on a tap. */ @Composable fun TranscriptList( @@ -69,8 +62,7 @@ fun TranscriptList( modifier = // Timed in two halves because the frame's draw phase is where Compose's measurement // lands, and "draw is high while nothing is being recorded" does not say which - // half; - // see [drawAccounting]. Measure includes composing the items that scrolled in. + // half. Measure includes composing the items that scrolled in. modifier .layout { measurable, constraints -> val started = System.nanoTime() @@ -104,8 +96,7 @@ fun TranscriptList( } // Standing in for everything not fetched yet. Only here while there is more -- its // appearance at the top edge is also roughly when the next page is asked for, so what - // it - // reports is a fetch in flight rather than an end reached. + // it reports is a fetch in flight rather than an end reached. if (moreHistory) { item(key = "history", contentType = "history") { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt index b716009..8a7bdcd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt @@ -9,14 +9,12 @@ import java.util.concurrent.atomic.AtomicReference * rest. * * One seam rather than a cache the screen has to remember to consult. Everything it fetched before - * -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for - * -- is asked of this, and everything the server sends is written into the cache on the way past, - * so the screen never learns which side answered. What it does learn, through [DebugStats], is how - * often each one did, which is how the saving is measured. + * is asked of this, and everything the server sends is written into the cache on the way past, so + * the screen never learns which side answered. What it does learn, through [DebugStats], is how + * often each one did. * - * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never - * load-bearing. Every read has a network path beside it producing the same result, so a missing, - * evicted or damaged cache degrades to exactly what this screen did before it existed. + * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing. + * Every read has a network path beside it producing the same result. */ class TranscriptSource( private val settings: ServerSettings, @@ -51,18 +49,17 @@ class TranscriptSource( * * The screen must not resume a stream from a cached seq unless it is the same conversation. A * transcript is append-only in ordinary use, but the file can be replaced or truncated -- a - * sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session - * re-imported -- and the server's catch-up on such a file would hand this phone a continuation - * of a *different* conversation, spliced onto the cached one with no seam. That is the worst - * thing this feature can do, and it is caught with one request of a few hundred bytes, in the - * slot the opening page's request used to be in. + * sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the + * server's catch-up on such a file would hand this phone a continuation of a *different* + * conversation, spliced onto the cached one with no seam. Caught with one request of a few + * hundred bytes, in the slot the opening page's request used to be in. * * False purges the cache and means "open cold". A throw is the server not being askable, which - * is neither: the cached rows stay on screen, the failure goes on the stream banner, and the - * caller tries again on the stream's own reconnect schedule. + * is neither: the cached rows stay on screen and the caller tries again on the reconnect + * schedule. * * What this cannot see is a line changed in the middle of the file with the tail intact. That - * is what the Reload button in session settings is for, and its caption says so. + * is what the Reload button in session settings is for. */ suspend fun probe(): Boolean { val tail = cache.tail() ?: return false @@ -100,8 +97,7 @@ class TranscriptSource( * row count takes it -- a single reply is hundreds of lines -- so a page fetched after the * reader has been away would run straight past the cached run and overlap it, and an * overlapping page cannot be stored. Told where this phone's copy starts, the server stops - * there instead, the gap is closed with exactly the bytes it was wide, and the history behind - * it is served locally from then on. + * there instead. */ suspend fun page(before: Long, limit: Int, coalesce: Boolean): List { cache.page(before, limit, rows = coalesce)?.let { lines -> @@ -169,10 +165,8 @@ private const val OPENING_WINDOW = 80 * * Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the * server, which Android may delete under storage pressure without asking. Keyed by host and port - * because two servers can hold a session with the same id -- the sandbox and the real server, or a - * re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1` - * is the layout's version: a change to it bumps the segment, and a directory of another version is - * deleted the first time this is called. + * because two servers can hold a session with the same id, and a line from one shown against the + * other is the whole invariant broken. `v1` is the layout's version. */ fun cacheRoot(context: Context, settings: ServerSettings): File { val transcripts = File(context.cacheDir, "transcripts") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt index 520951a..2733f54 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp * at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be * twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when * the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst - * frame is bounded. This is the piece that was missing when a lazy list was last tried here; the - * block splitting existed only inside the row, where the list could not see it. + * frame is bounded. This is the piece that was missing when a lazy list was last tried here. * * Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit * points back at its row. The list draws units; anchors and paging still speak seq. @@ -27,10 +26,9 @@ sealed class TranscriptUnit { abstract val seq: Long /** - * This unit's position within its row, counted from the row's oldest end. - * - * What a saved scroll position carries besides the seq: a reply split into forty blocks needs - * more than "somewhere in this row" to put a reader back where they stopped. + * This unit's position within its row, counted from the row's oldest end. What a saved scroll + * position carries besides the seq: a reply split into forty blocks needs more than "somewhere + * in this row" to put a reader back where they stopped. */ abstract val ordinal: Int @@ -66,15 +64,13 @@ sealed class TranscriptUnit { * * A peer message is the one row whose *opened* size is unbounded -- these are the longest * things a transcript holds -- so it is flattened the same way a settled reply is, and for the - * same reason: as one item, every block of it is composed, measured, placed and kept alive - * while any part of it is on screen. Measured on the emulator, opening a 43KB one took the - * transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own - * per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to - * 3.15ms. + * same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the + * draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from + * 0.39ms to 3.15ms. * * The card is drawn in pieces rather than given up: a filled Material card is elevation zero, * so it has no shadow to break, and each piece paints the same fill with only the corners it - * owns. See [PeerHeadRow] and [PeerBlockRow]. + * owns. */ data class PeerHead( override val seq: Long, @@ -84,8 +80,7 @@ sealed class TranscriptUnit { ) : TranscriptUnit() { /** * The note's own key, so opening and shutting does not change what the list is anchored on - * -- and so two notes stamped with one turn's seq are still two items. See - * [TranscriptItem.PeerNote]. + * -- and so two notes stamped with one turn's seq are still two items. */ override val key: Any get() = item.key @@ -119,8 +114,7 @@ sealed class TranscriptUnit { * * A user message is plain text, so cutting it costs a scan rather than a parse -- but the * reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand - * pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the - * `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report. + * pixels of `Text` whose layout lands in the frame the row scrolls into. */ data class UserChunk( override val seq: Long, @@ -152,14 +146,11 @@ sealed class TranscriptUnit { * The rows flattened into list units, newest first -- index zero is the item at the bottom of the * screen, which is what a reversed lazy list calls the start. * - * Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is - * only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are, - * which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting. - * The reply still arriving -- the newest row, until the status event that ends its turn marks it - * [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and - * splitting it here would parse the whole message per delta on whichever thread is composing. - * [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the - * live message a layer per piece. Once settled it splits like every other reply, which is what + * Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever + * cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one + * is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text + * changes with every delta, and splitting it here would parse the whole message per delta on + * whichever thread is composing. Once settled it splits like every other reply, which is what * bounds the newest row's cost after a session ends on a long one. * * Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm @@ -200,8 +191,8 @@ fun transcriptUnits( } } } else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) { - // A scan, not a parse, so it is cheap enough for the fold path -- and cached like - // the markdown splits so the scan too happens once per message, not once per fold. + // A scan, not a parse, so it is cheap enough for the fold path -- and cached like the + // markdown splits so the scan happens once per message rather than once per fold. val chunks = replies.chunksOf(item.text) chunks.forEachIndexed { at, chunk -> units += @@ -252,8 +243,8 @@ fun transcriptUnits( } units.reverse() reportDuplicateKeys(units) - // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" - // is this number growing, and it was invisible until it was written down. + // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is + // this number growing, and it was invisible until it was written down. DebugStats.record("units flattened", System.nanoTime() - started) return units } @@ -261,9 +252,8 @@ fun transcriptUnits( /** * Whether this reply should be drawn as blocks: settled, or anywhere but the newest row. * - * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two - * questions are separate because they are answered by different things: this one by the fold, the - * other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them. + * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are + * answered by different things: this one by the fold, the other by whether [warm] has run. */ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) = item.settled || index != lastIndex @@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex * The replies among [rows] that should draw as blocks but whose parses are not made yet. * * Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold - * is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn - * ends its split would cost a whole-message parse on the composing thread. The session screen warms + * is the reply that just finished streaming -- nothing warms live deltas. The session screen warms * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against * ready parses. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt index d53df3b..5794ff6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt @@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable * on the main thread -- so it is not an error the screen can show, it closes the app. That is a * disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on * whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id - * recorded under two project directories, which is an ordinary state of a machine and not something - * the phone did. + * recorded under two project directories, which is an ordinary state of a machine. * * Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared - * the hazard and none of them could rule it out locally. Hence one function they all go through - * rather than a `distinctBy` remembered at each call site. + * the hazard and none could rule it out locally. Hence one function they all go through. * - * Dropping the repeat is the right answer here because the key is the whole identity: two rows with - * one id are two rows every action would treat as the same thing, so there is nothing to show about - * the second that the first is not already showing. Where the duplicate means something -- the - * import list's did -- the fix belongs at the source, and this is only what stops a data problem - * from being a crash. It is counted so the render report says it happened rather than leaving a - * silently shorter list. + * Dropping the repeat is right here because the key is the whole identity: two rows with one id are + * two rows every action would treat as the same thing. Where the duplicate means something, the fix + * belongs at the source, and this is only what stops a data problem from being a crash. It is + * counted so the render report says it happened rather than leaving a silently shorter list. * * The transcript's own list is deliberately not on this: its keys are made here rather than * received, and it is the one list where an extra pass over the items is measurable. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index b2bf44d..a95e071 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -27,17 +27,14 @@ import java.time.OffsetDateTime * A dialog rather than a screen. Usage is something you check *against* what you were reading -- * "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for * it took the session away to answer a question about the session. It also has no navigation of its - * own: there is nothing here to open, so the only thing its Back could ever have meant was "put - * this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog` - * handles that itself. + * own, so the only thing its Back could ever have meant was "put this away". */ @Composable fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { - // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the - // gaps between its title, its content and its buttons at sizes meant for a sentence of prose - // and a decision; this is a dense read-out, and those gaps left a band of empty dialog above - // Close that was taller than a bar. Everything else here is what AlertDialog would have - // drawn -- the same container colour, the same corner -- so nothing about it looks foreign. + // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps + // between its title, content and buttons at sizes meant for a sentence of prose and a decision; + // this is a dense read-out, and those gaps left a band of empty dialog above Close that was + // taller than a bar. Dialog(onDismissRequest = onDismiss) { Surface( shape = MaterialTheme.shapes.extraLarge, @@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { modifier = Modifier.fillMaxWidth(), ) { // Deliberately not subtitled with the provider this was opened from. These - // numbers belong to an account on a particular machine, reported by whichever - // paid service answered there -- naming the session's provider here made an - // echo session's screen read "echo" above a line reading "claude", which is a - // claim about echo that nothing measured. Each machine names itself and the - // service it came from, which is the true scope. + // numbers belong to an account on a particular machine -- naming the session's + // provider here made an echo session's screen read "echo" above a line reading + // "claude". Each machine names itself and the service it came from. Text( "Usage", style = MaterialTheme.typography.headlineSmall, @@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { } } Spacer(Modifier.height(8.dp)) - // Scrolls rather than being trimmed: a machine can report any number of windows - // and there can be any number of machines, and a dialog is the one place where - // running out of room is silent. `fill = false` so a short read-out keeps a short - // dialog instead of stretching to the window. + // Scrolls rather than being trimmed: a machine can report any number of windows and + // there can be any number of machines, and a dialog is the one place where running + // out of room is silent. `fill = false` so a short read-out keeps a short dialog. Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { UsageBody(feed.snapshots) } @@ -93,8 +87,8 @@ private fun UsageBody(state: LoadState>) { is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Loaded -> if (current.value.isEmpty()) { - // Not an error and not a blank screen: no machine offers a paid service, - // so there is genuinely nothing to report and saying so is the answer. + // Not an error and not a blank screen: no machine offers a paid service, so + // there is genuinely nothing to report and saying so is the answer. Text( "No machine here runs anything with usage limits.", style = MaterialTheme.typography.bodyMedium, @@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState>) { } else { // No card around each machine. A card is a step up the surface ladder, and // inside a dialog -- itself a raised surface -- the step barely renders while - // costing 16dp of padding on every side. What separates one machine from the - // next is the line naming it, which is enough for a list this short. + // costing 16dp on every side. What separates one machine from the next is the + // line naming it. current.value.forEachIndexed { index, snapshot -> if (index > 0) { Spacer(Modifier.height(20.dp)) } - // Machine and service on one line: which account these numbers belong to - // is decided by both together, and stacked as a heading over a subtitle - // they read as a section of their own rather than as the label they are. - // Small and quiet, because the numbers below are what somebody opened - // this to see. + // Machine and service on one line: which account these numbers belong to is + // decided by both together, and stacked as a heading over a subtitle they + // read as a section of their own. Small and quiet, because the numbers + // below are what somebody opened this to see. Text( "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}", style = MaterialTheme.typography.bodySmall, @@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState>) { ) SnapshotState(snapshot) snapshot.windows.forEachIndexed { windowIndex, window -> - // Between the bars, not after the last one: a trailing gap here is - // what put a band of empty dialog above the Close button. + // Between the bars, not after the last one: a trailing gap here is what + // put a band of empty dialog above the Close button. if (windowIndex > 0) { Spacer(Modifier.height(12.dp)) } @@ -139,8 +132,7 @@ private fun UsageBody(state: LoadState>) { * * The distinction the old single message could not draw. A machine nobody has logged in on is * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be - * the interface nagging about a decision already made, and would dilute the marks that do mean - * something. Only the two faults are coloured as faults. + * the interface nagging about a decision already made. Only the two faults are coloured as faults. */ @Composable private fun SnapshotState(snapshot: UsageSnapshot) { @@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // Reached but refused, versus never reached at all: different things to go and do, - // so they say different things rather than sharing one "unavailable". + // Reached but refused, versus never reached at all: different things to go and do, so they + // say different things rather than sharing one "unavailable". "failed" -> Text( snapshot.detail ?: "Couldn't read the limits from this machine.", @@ -200,10 +192,9 @@ private fun WindowBar(window: UsageWindow) { /** * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. * - * Null for a window that is not running, which is the case this row has always drawn as nothing and - * is right to: there is no end to report. What it used to get wrong is the other missing case, a - * timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared - * as an ISO string in the middle of a sentence written for a person. Both cases are named in + * Null for a window that is not running: there is no end to report. What this used to get wrong is + * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse + * failure appeared as an ISO string in a sentence written for a person. Both are named in * [WindowEnd], and the session bar words them the same way. */ private fun resetLine(window: UsageWindow): String? =