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");