From 66b3403a71e5c253c32db1df9b2e926147595b83 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 21 Sep 2026 02:51:19 -0400 Subject: [PATCH] Read a session's transcript as the file it is The conversation on screen is a drawing of the record, and when the two disagree -- or when something in the record is what has gone wrong -- there was no way to see the record itself from the phone. View raw in the session settings dialog opens the file explorer on the transcript, which is its third caller and needed no new screen: the file name and path in the header, a back button, and the lines as code. The session says where the file is, because only the backend knows, and it names this backend's machine rather than the session's -- for a remote session those are two different filesystems. Back from the file lands in the session's own directory, where the log and the process record are. The paragraph under Reload goes, and both buttons move to a line of their own: two buttons and a measurement do not fit a phone's width. --- AGENTS.md | 6 +++ EXPLORER.md | 12 ++++++ .../src/main/kotlin/com/example/aiapp/Api.kt | 17 ++++++++ .../kotlin/com/example/aiapp/FilesScreen.kt | 14 +++++++ .../kotlin/com/example/aiapp/SessionScreen.kt | 10 +++++ .../example/aiapp/SessionSettingsDialog.kt | 29 ++++++++----- server/src/routes.rs | 5 ++- server/src/session/mod.rs | 42 ++++++++++++++++++- 8 files changed, 123 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3b1b19..6cb85c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,12 @@ Module-by-module intent is in PLAN.md's "Backend layout". (`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt` and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the choices made there that are still awaiting review. + **The transcript file itself is readable from the session settings dialog** + (2026-09-21): *View raw* opens the file explorer on it, from + `transcriptFile` on `GET /sessions/{id}` -- which names the machine *this + backend* runs on, not the session's. It is the explorer's third caller and + needed no new screen; a transcript past `FILE_LIMIT` (1 MiB) is refused the + way any other large file is. - `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it diff --git a/EXPLORER.md b/EXPLORER.md index 8871f76..3a13c32 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -37,6 +37,18 @@ Rejected: routes under `/sessions/{id}/`. The session would be a detour to find the machine, and "browse this machine" from anywhere else would need a session to exist first. +The third caller arrived 2026-09-21 and cost no code here, which is the +property this decision was made for: **View raw** in the session settings +dialog opens the explorer on the session's own transcript file +(`fileTarget`), so the record can be read as it is on disk rather than only +as the conversation drawn from it. The session says where the file is +(`transcriptFile` on `GET /sessions/{id}`) because only the backend knows -- +and it names **this backend's** machine rather than the session's, which for +a remote session are two different filesystems. Back from the file lands in +the session's own directory, where the log and the process record are. +A transcript past `FILE_LIMIT` is refused the same way any other large file +is, which is the known limit of this as a debugging tool. + ### 2. One shell script per operation, over `Transport`, on both transports Each operation is a small POSIX script handed to `sh -c script sh "$path" …` 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 4cda6d8..43ea3b0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -243,8 +243,17 @@ data class SessionSummary( val lastActivity: Double, /** Latest measured number of live background tasks; zero also covers older servers. */ val backgroundTasks: Int, + /** + * Where the transcript file itself is, for reading the record rather than the conversation + * drawn from it. Null from a server that does not say, and from one whose own machine is not in + * its config -- there is then nothing that could read the file, and no way in is offered. + */ + val transcriptFile: FileOnMachine?, ) +/** A file somewhere the explorer can be pointed at: which machine, and the path on it. */ +data class FileOnMachine(val machine: String, val machineName: String, val path: String) + private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), @@ -277,6 +286,14 @@ private fun parseSession(session: JSONObject) = status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), backgroundTasks = session.optInt("backgroundTasks", 0), + transcriptFile = + session.optJSONObject("transcriptFile")?.let { file -> + FileOnMachine( + machine = file.getString("machine"), + machineName = file.getString("machineName"), + path = file.getString("path"), + ) + }, ) fun fetchSessions(settings: ServerSettings): List = 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 af6e22b..99eb9a7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -56,6 +56,20 @@ data class FilesTarget( val file: String? = null, ) +/** + * The explorer opened on one file, wherever that file is. + * + * Its directory is what the reader lands in on the way back, which for a session's transcript is + * that session's own directory -- the log, the process record and the rest of what it wrote. + */ +fun fileTarget(file: FileOnMachine) = + FilesTarget( + machine = file.machine, + machineName = file.machineName, + start = parentOf(file.path) ?: "/", + file = file.path, + ) + /** The explorer target for this session's machine, optionally opened on [file]. */ fun SessionSummary.filesTarget(file: String? = null) = FilesTarget( 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 6f8e761..178eac9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -2507,6 +2507,16 @@ fun SessionScreen( // 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. The // epoch is what makes the rest a cold open. + // The explorer, opened on the transcript file: the record itself, in the screen this + // app already has for reading a file on a machine. Nothing is offered where the server + // does not say where the file is. + onViewRaw = + summary.transcriptFile?.let { file -> + { + settingsOpen = false + onFiles(fileTarget(file)) + } + }, onReload = { settingsOpen = false scope.launch { 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 088f1a0..39aabe3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -99,6 +100,11 @@ fun SessionSettingsDialog( */ cachedBytes: Long?, onReload: () -> Unit, + /** + * Opens the transcript file itself in the explorer. Null from a server that does not say where + * it is, which draws no button rather than one that cannot work. + */ + onViewRaw: (() -> Unit)?, onDismiss: () -> Unit, /** * Copies what this session costs to draw. Built by the session screen, because everything it @@ -524,21 +530,24 @@ fun SessionSettingsDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(Modifier.width(12.dp)) + } + // Both on a line of their own under what they act on, rather than crowded against + // the size on the line above: two buttons and a measurement do not fit the width + // of a phone, and the one that would lose is the number. + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // The record as it is on disk, for the question the drawn conversation cannot + // answer -- which is most of what anybody opens this dialog to debug. + onViewRaw?.let { TextButton(onClick = it) { Text("View raw") } } + Spacer(Modifier.width(8.dp)) // Enabled whether or not anything is cached: "what I see disagrees with the // machine" is a state an empty cache can be in too, and a control that comes // and goes makes its own presence the signal. TextButton(onClick = onReload) { Text("Reload") } } - // Captioned, unlike the controls above it, for the same reason Move is: what it - // costs is not visible, and neither is the case it exists for. - Text( - "Reload throws away this phone's copy and fetches the transcript from the " + - "server again. Use it when what is shown here disagrees with the file " + - "on the machine.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) error?.let { Spacer(Modifier.height(8.dp)) Text( diff --git a/server/src/routes.rs b/server/src/routes.rs index ffd2c0c..77f8532 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -37,7 +37,10 @@ //! PUT /machines/{id} rename {name?} and/or re-probe {rediscover?} //! DELETE /machines/{id} remove, refused while sessions use it //! GET /sessions list (id, provider, title, model, status, last activity) -//! GET /sessions/{id} one session, for refetching after a change +//! GET /sessions/{id} one session, for refetching after a change. Its +//! `transcriptFile` is where the record itself is -- +//! {machine, machineName, path} -- so the explorer can be +//! pointed at it //! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?} //! POST /sessions/order {sessions} -- the list in the order it is drawn in, //! as the reader dragged it; ids left out keep their diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 3a830b5..1385b32 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -316,6 +316,10 @@ pub struct SessionInfo { /// Absent until it reports one; absence is not a measured zero. #[serde(skip_serializing_if = "Option::is_none")] pub background_tasks: Option, + /// Where the transcript file itself is, for a reader who wants the record + /// rather than the conversation drawn from it -- see [`TranscriptFile`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript_file: Option, /// How many subagents this session has started, from a directory /// listing rather than reading each one's status -- see /// `GET /sessions/{id}/subagents` for that. 0 when it has none, not @@ -323,6 +327,37 @@ pub struct SessionInfo { pub subagents: usize, } +/// Where a session's transcript file is, as somewhere the file explorer can +/// be pointed at. +/// +/// The machine as well as the path, and it is **this backend's** machine +/// rather than the session's: the file is written here whichever machine the +/// session runs on, and for a remote session those are two different +/// filesystems. `None` where this backend's own machine is not in the config +/// at all, which is the state in which nothing could read the file anyway -- +/// and the phone then offers no way in, rather than one that fails. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptFile { + pub machine: String, + pub machine_name: String, + pub path: String, +} + +/// Where `id`'s transcript is, said the way the explorer takes it. +fn transcript_file(config: &Config, data_dir: &Path, id: &str) -> Option { + let machine = config.machine(crate::config::LOCAL_MACHINE_ID)?; + Some(TranscriptFile { + machine: machine.id.clone(), + machine_name: machine.name.clone(), + path: data_dir + .join(id) + .join("transcript.jsonl") + .to_string_lossy() + .into_owned(), + }) +} + /// What is running a session at this moment, and `None` when nothing is. /// /// Behind a lock because a session outlives its process: stopping one and @@ -673,6 +708,7 @@ impl LiveSession { current: &SessionConfig, imported: bool, kind: Option, + transcript_file: Option, ) -> SessionInfo { SessionInfo { id: self.meta.id.clone(), @@ -717,6 +753,7 @@ impl LiveSession { .driver() .and_then(|driver| driver.background_tasks()) .map(|tasks| tasks.len()), + transcript_file, subagents: subagent::count(self.dir()), } } @@ -1234,6 +1271,7 @@ impl SessionManager { meta, import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), kind_of(&inner.config, &meta.machine, &meta.provider), + transcript_file(&inner.config, &self.data_dir, &meta.id), ), None => SessionInfo { id: meta.id.clone(), @@ -1274,6 +1312,7 @@ impl SessionManager { last_activity: meta.created, created: meta.created, background_tasks: None, + transcript_file: transcript_file(&inner.config, &self.data_dir, &meta.id), subagents: subagent::count(&self.data_dir.join(&meta.id)), }, }) @@ -1491,6 +1530,7 @@ impl SessionManager { &session.meta, import::read_cursor(&self.data_dir.join(&id)).is_some(), Some(provider.kind), + transcript_file(&inner.config, &self.data_dir, &id), ); inner.live.insert(id, session); Ok(info) @@ -3487,7 +3527,7 @@ mod tests { // open to look one up on. assert_eq!( first.title, - session.info("m", &session.meta, false, None).title + session.info("m", &session.meta, false, None, None).title ); manager.set_session_notify(&info.id, false).expect("off");