From 6d5fd64bb06db6f919b8d5b8505f5bceba0684ad Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 12:55:22 -0400 Subject: [PATCH 1/5] client-core: EnrolledServer, the aiapp:// enrol-link parser (RUST.md's E4) A Rust client needs the same host/port/token an Android phone gets from scanning an aiapp://enroll?... QR, so a desktop build can enrol from the identical text pasted rather than a second format invented for it. Co-Authored-By: Claude Fable 5.1 --- client-core/src/config.rs | 152 ++++++++++++++++++++++++++++++++++++++ client-core/src/lib.rs | 1 + 2 files changed, 153 insertions(+) create mode 100644 client-core/src/config.rs diff --git a/client-core/src/config.rs b/client-core/src/config.rs new file mode 100644 index 0000000..99e420c --- /dev/null +++ b/client-core/src/config.rs @@ -0,0 +1,152 @@ +//! What a Rust client needs to reach one enrolled server: host, port and +//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s +//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T` +//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and +//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from +//! the same text a phone would scan as a QR, with no second format +//! invented for it (RUST.md's E4). +//! +//! What this type deliberately does not decide: where it is persisted, and +//! under what file permissions. A phone seals its token in the Android +//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME//` +//! directory and its own file-mode conventions (MACHINE.md: owner-only, +//! never in the repo). Both are caller-specific, so they stay out of this +//! crate per the code rules' "ask for the least you need" -- see +//! `iris/desktop-app/src/config.rs` for the desktop instance. + +use serde::{Deserialize, Serialize}; + +/// One enrolled server: reachable at `https://{host}:{port}`, authenticated +/// with `token` as a bearer header. Does not carry the pinned CA -- that is +/// a public certificate rather than a secret, and where to find it differs +/// by caller (a phone pins the one its APK was built against; a desktop +/// client is told a path). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnrolledServer { + pub host: String, + pub port: u16, + pub token: String, +} + +impl EnrolledServer { + /// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not + /// matter; unrecognised keys are ignored). `token` is percent-decoded, + /// since `ui-sandbox.sh` encodes it precisely because a raw token can + /// contain `+`, which turns into a space if left to a naive splitter. + pub fn parse_link(link: &str) -> Result { + let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| { + format!( + "'{link}' has no query string (expected \ + aiapp://enroll?host=...&port=...&token=...)" + ) + })?; + + let mut host = None; + let mut port = None; + let mut token = None; + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + let value = percent_decode(value); + match key { + "host" => host = Some(value), + "port" => port = Some(value), + "token" => token = Some(value), + _ => {} + } + } + + let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?; + let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?; + let port: u16 = port_str + .parse() + .map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?; + let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?; + + Ok(Self { host, port, token }) + } + + /// Where a `client_core::api::UreqTransport` reaches this server. + pub fn base_url(&self) -> String { + format!("https://{}:{}", self.host, self.port) + } +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_host_port_and_token() { + let server = + EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123") + .unwrap(); + assert_eq!( + server, + EnrolledServer { + host: "127.0.0.1".to_string(), + port: 8547, + token: "abcDEF123".to_string(), + } + ); + assert_eq!(server.base_url(), "https://127.0.0.1:8547"); + } + + #[test] + fn field_order_does_not_matter() { + let server = + EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com") + .unwrap(); + assert_eq!(server.host, "example.com"); + assert_eq!(server.port, 443); + assert_eq!(server.token, "tok"); + } + + #[test] + fn a_percent_encoded_token_is_decoded() { + // ui-sandbox.sh's own reason for encoding: a raw '+' would + // otherwise arrive as a space. + let server = + EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap(); + assert_eq!(server.token, "a+b/c"); + } + + #[test] + fn a_missing_field_is_named_in_the_error() { + let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err(); + assert!( + err.contains("token"), + "error should name the missing field: {err}" + ); + } + + #[test] + fn a_non_numeric_port_is_named_in_the_error() { + let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err(); + assert!( + err.contains("port"), + "error should name the offending field: {err}" + ); + } +} diff --git a/client-core/src/lib.rs b/client-core/src/lib.rs index 0e4d8d6..80f65f0 100644 --- a/client-core/src/lib.rs +++ b/client-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod ansi; pub mod api; +pub mod config; pub mod event_stream; pub mod highlight; pub mod notifications; From 8f0aec449aff86000746ae88c3ae166b212f0cbf Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 12:55:29 -0400 Subject: [PATCH 2/5] transcript-ui: build_tree, the screen without claiming the window root (RUST.md's E4) build() always finished by calling ui_state.set_root(), which is right for a window that *is* the transcript screen and wrong for a caller embedding it beside something else (the desktop app's session list). build_tree() is build() minus that last step, returning the widget tree instead of planting it; build() is now one line on top of it, so nothing else changes for existing callers. Co-Authored-By: Claude Fable 5.1 --- iris/transcript-ui/src/lib.rs | 37 ++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index dfe4809..07a8827 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -88,6 +88,25 @@ pub fn build( ui_state: &mut impl HasRoot, rows: Vec, ) -> TranscriptScreen +where + Rsc::State: FocusHost, +{ + let (screen, tree) = build_tree(rsc, rows); + ui_state.set_root(tree); + screen +} + +/// The same widget tree [`build`] makes, without claiming the window's +/// whole root -- what a caller embedding this screen alongside something +/// else of its own needs (RUST.md's E4: a session list beside the +/// transcript on the desktop). `build` is `build_tree` plus +/// `ui_state.set_root(tree)`; kept as its own function since most callers +/// (the winit example, an eventual Android cdylib) want the screen to *be* +/// the window and don't need the strong handle back. +pub fn build_tree( + rsc: &mut Rsc, + rows: Vec, +) -> (TranscriptScreen, StrongWidget) where Rsc::State: FocusHost, { @@ -111,13 +130,17 @@ where let (composer, composer_bar) = composer::build_composer(rsc); - (list.width(rest(1)).height(rest(1)), composer_bar) + let tree = (list.width(rest(1)).height(rest(1)), composer_bar) .span(Dir::DOWN) - .set_root(rsc, ui_state); + .add_strong(rsc) + .any(); - TranscriptScreen { - list, - composer, - selection, - } + ( + TranscriptScreen { + list, + composer, + selection, + }, + tree, + ) } From 73ee63bc1b2e5f6243134c8f92b2dd281ee2a2fd Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 12:55:41 -0400 Subject: [PATCH 3/5] iris: desktop-app, a winit window for the transcript screen (RUST.md's E4) The pass condition was the same screen, from the same crate, running in a window with only the layout differing. desktop-app is a new workspace member: a session list (plain iris::widget::Span, rebuilt on selection) beside transcript_ui::build_tree's screen, talking to a real ai-server through client-core's ApiClient/UreqTransport/follow_session_events, with background network I/O on plain std::threads reporting back through winit's EventLoopProxy rather than iris's Tasks (which only redraws once per async closure, not once per SSE event). Both pass-condition proofs held against app/ui-sandbox.sh's real server: the list showed a spawned session, selecting it loaded its transcript, and a message sent from the composer streamed its reply back live. Along the way, a real bug: resuming the SSE stream from a folded item's seq (which for a still-open assistant message is its *first* delta's seq by design) replayed already-folded deltas and duplicated the tail of the reply -- found by a run-headless.sh screenshot, fixed by resuming from the raw wire seq instead, and covered by a regression test. Deliberately simple and said so in app.rs's module doc: every SSE event refolds the whole transcript and rebuilds the right-hand tree from scratch rather than reaching for TranscriptScreen::push_row's incremental append, since a streaming reply is a row whose text keeps changing after it appears and push_row can only add a new one. Fine at a desktop session's scale; the real fix needs transcript-ui to expose updating a row in place. Android is untouched by this step. Co-Authored-By: Claude Fable 5.1 --- iris/Cargo.lock | 13 + iris/Cargo.toml | 2 +- iris/desktop-app/Cargo.toml | 27 ++ iris/desktop-app/src/app.rs | 538 +++++++++++++++++++++++++++++++++ iris/desktop-app/src/config.rs | 134 ++++++++ iris/desktop-app/src/main.rs | 95 ++++++ 6 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 iris/desktop-app/Cargo.toml create mode 100644 iris/desktop-app/src/app.rs create mode 100644 iris/desktop-app/src/config.rs create mode 100644 iris/desktop-app/src/main.rs diff --git a/iris/Cargo.lock b/iris/Cargo.lock index d37cdf1..d39c8fb 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -922,6 +922,19 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "desktop-app" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "serde_json", + "tempfile", + "transcript-ui", + "winit", +] + [[package]] name = "dispatch" version = "0.2.0" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index f251bbd..ddb4445 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -73,7 +73,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui", "transcript-ui"] +members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate diff --git a/iris/desktop-app/Cargo.toml b/iris/desktop-app/Cargo.toml new file mode 100644 index 0000000..36547a5 --- /dev/null +++ b/iris/desktop-app/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "desktop-app" +version.workspace = true +edition.workspace = true + +# RUST.md's E4: the same transcript-ui screen (I5) in a winit window on the +# desktop, beside a session list, talking to a real `ai-server` through +# `client-core`'s REST + SSE clients. Enrolment reuses the phone's own +# `aiapp://enroll?...` link (`client-core::config`) rather than inventing a +# second format -- see DECISIONS.md's 2026-09-05 entry. An ordinary +# workspace member (unlike `android-app`): nothing here needs the NDK, so +# `cargo build --workspace --all-targets` at the host stays clean with it +# included. + +[dependencies] +iris = { path = ".." } +transcript-ui = { path = "../transcript-ui" } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +# Already pulled in transitively through client-core; used directly here +# only to persist `EnrolledServer` as the app's own tiny config file (see +# `config.rs`) -- no new dependency. +serde_json = { version = "1", features = ["float_roundtrip"] } +winit = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/iris/desktop-app/src/app.rs b/iris/desktop-app/src/app.rs new file mode 100644 index 0000000..28e86d2 --- /dev/null +++ b/iris/desktop-app/src/app.rs @@ -0,0 +1,538 @@ +//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5) +//! filling the rest, both against a real `ai-server` reached through +//! `client-core`. The layout is the simplest thing that shows both at +//! once -- a fixed-width column and `rest(1)` for everything else, using +//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches +//! panes, rather than anything desktop-specific: +//! +//! ```text +//! +-----------+--------------------------------------+ +//! | session | transcript_ui::TranscriptScreen | +//! | list | (List of folded rows + composer) | +//! | (WidgetPtr| | +//! | swapped | (WidgetPtr swapped whole on session | +//! | on data) | switch or a new transcript event) | +//! +-----------+--------------------------------------+ +//! ``` +//! +//! **Deliberately left simple, and why**: every incoming SSE event refolds +//! the *entire* transcript (`client_core::transcript_fold::fold_event` is +//! already `O(items)` and a desktop session's conversation is small) and +//! rebuilds the whole right-hand widget tree from scratch, rather than +//! reaching for `TranscriptScreen::push_row`'s incremental append. +//! `push_row` cannot update a row already on screen -- only append a new +//! one -- and a streaming assistant reply is exactly a row whose *text* +//! keeps changing after it first appears (see `transcript-ui`'s own doc on +//! `fold_event` folding deltas into one growing item). A full rebuild +//! shows that growth correctly at the cost of redrawing everything each +//! time; fine for this proof, wrong for a long, fast-streaming transcript +//! -- the incremental path that fixes it needs `transcript-ui` to expose +//! updating a row in place, which it does not yet. The composer's +//! in-progress text survives a rebuild (`rebuild_transcript`'s +//! `in_progress` local) since the user typing a followup while a reply +//! streams in is the one case a naive rebuild would otherwise lose data +//! on. +//! +//! Background network I/O (`client_core::api`/`event_stream`, both +//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain +//! `std::thread`s that report back through `winit`'s `EventLoopProxy` +//! (`Proxy`), rather than through iris's own `Tasks`/`task_on`: +//! `Tasks` only requests a redraw once, after its whole async closure +//! finishes, which fits a single request-then-update but not a live SSE +//! loop that needs to be seen redrawing after *each* event it relays. +//! `Proxy::send_event` wakes the window's event loop immediately, once per +//! event, which is what a stream wants. + +use client_core::api::{ApiClient, SessionSummary, UreqTransport}; +use client_core::event_stream::{StreamItem, follow_session_events}; +use client_core::transcript_fold::{TranscriptItem, fold_event, group_tool_runs}; +use event_model::SeqEvent; +use iris::prelude::*; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// The session list column's width -- a fixed size for the simplest +/// layout that shows both panels at once (UI_RULES's text-truncation and +/// no-shrink rules apply to what's drawn inside it, not to this choice of +/// column width itself). +const LIST_WIDTH: f32 = 260.0; + +/// Everything a background thread hands back to the window's event loop. +/// `generation` on the session-scoped variants is the generation +/// `select_session` was on when the thread started (`Client::generation`) +/// -- compared back against the current one before being applied, so a +/// slow response from a session the reader has since clicked away from +/// can't overwrite what replaced it. +enum AppEvent { + Sessions(Result, String>), + TranscriptLoaded { + session_id: String, + generation: u64, + result: Result, String>, + }, + StreamEvent { + session_id: String, + generation: u64, + event: SeqEvent, + }, + StreamEnded { + session_id: String, + generation: u64, + message: Option, + }, + SendFailed(String), +} + +pub fn run() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +struct Client { + ui_state: DefaultUiState, + api: Arc>, + /// A second, independent `UreqTransport` to the same server, used only + /// by `select_session`'s live-follow loop. `ApiClient` keeps its + /// transport private (rightly -- nothing outside it should reach past + /// the typed calls), so a caller that also needs the raw + /// `Transport::stream` for SSE, as this one does, builds its own + /// rather than the crate growing a getter whose only purpose would be + /// letting one caller reach around its own abstraction. + stream_transport: Arc, + proxy: Proxy, + sessions: Vec, + selected: Option, + items: Vec, + list_ptr: WeakWidget, + transcript_ptr: WeakWidget, + screen: Option, + /// Bumped every time the selected session changes; see `AppEvent`'s + /// doc for what it guards against. + generation: Arc, +} + +impl DefaultAppState for Client { + type Event = AppEvent; + + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + proxy: Proxy, + ) -> Self { + // Re-validated here rather than threaded through from `main` -- + // `DefaultApp::run()` takes no payload, so there is no other way + // to get `main`'s parsed CLI/config into this constructor. `main` + // already called this once to fail fast before a window opens; + // this call only fails if the filesystem changed underneath the + // process in between, which is not a case worth a nicer message. + let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| { + eprintln!("desktop-app: {e}"); + std::process::exit(2); + }); + let build_transport = + || UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem); + let (rest_transport, stream_transport) = build_transport() + .and_then(|rest| build_transport().map(|stream| (rest, stream))) + .unwrap_or_else(|e| { + eprintln!( + "desktop-app: couldn't set up TLS to {}: {e}", + server.base_url() + ); + std::process::exit(1); + }); + let api = Arc::new(ApiClient::new(rest_transport)); + let stream_transport = Arc::new(stream_transport); + + let list_ptr = WidgetPtr::new().add(rsc); + let transcript_ptr = WidgetPtr::new().add(rsc); + let loading = placeholder(rsc, "Loading sessions..."); + transcript_ptr(rsc).set(loading); + + (list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1))) + .span(Dir::RIGHT) + .set_root(rsc, &mut ui_state); + + let client = Self { + ui_state, + api, + stream_transport, + proxy, + sessions: Vec::new(), + selected: None, + items: Vec::new(), + list_ptr, + transcript_ptr, + screen: None, + generation: Arc::new(AtomicU64::new(0)), + }; + client.spawn_fetch_sessions(); + client + } + + fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc, _render: &mut UiRenderState) { + match event { + AppEvent::Sessions(Ok(sessions)) => { + self.sessions = sessions; + self.rebuild_list(rsc); + if self.selected.is_none() { + self.show_message(rsc, "Select a session."); + } + } + AppEvent::Sessions(Err(message)) => { + self.show_message(rsc, &format!("Couldn't list sessions: {message}")); + } + AppEvent::TranscriptLoaded { + session_id, + generation, + result, + } => { + if self.current(&session_id, generation) { + match result { + Ok(items) => { + self.items = items; + self.rebuild_transcript(rsc); + } + Err(message) => { + self.show_message( + rsc, + &format!("Couldn't load {session_id}: {message}"), + ); + } + } + } + } + AppEvent::StreamEvent { + session_id, + generation, + event, + } => { + if self.current(&session_id, generation) { + self.items = fold_event(&self.items, &event); + self.rebuild_transcript(rsc); + } + } + AppEvent::StreamEnded { + session_id, + generation, + message: Some(message), + } => { + if self.current(&session_id, generation) { + eprintln!("desktop-app: {session_id}'s live connection ended: {message}"); + } + } + AppEvent::StreamEnded { .. } => {} + AppEvent::SendFailed(message) => { + eprintln!("desktop-app: couldn't send: {message}"); + } + } + self.ui_state.window.request_redraw(); + } +} + +impl Client { + fn current(&self, session_id: &str, generation: u64) -> bool { + self.selected.as_deref() == Some(session_id) + && self.generation.load(Ordering::SeqCst) == generation + } + + /// Replaces the right-hand panel with a line of text -- built before + /// `transcript_ptr` is reached for, since building the message and + /// swapping it in both need `rsc` and can't overlap as one borrow. + fn show_message(&mut self, rsc: &mut DefaultRsc, message: &str) { + let widget = placeholder(rsc, message); + (self.transcript_ptr)(rsc).set(widget); + } + + fn spawn_fetch_sessions(&self) { + let api = self.api.clone(); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let result = api.fetch_sessions().map_err(|e| e.to_string()); + let _ = proxy.send_event(AppEvent::Sessions(result)); + }); + } + + fn rebuild_list(&mut self, rsc: &mut DefaultRsc) { + let list = Span::empty(Dir::DOWN).gap(2).add(rsc); + for session in &self.sessions { + let selected = self.selected.as_deref() == Some(session.id.as_str()); + let row = session_row(rsc, session, selected); + list(rsc).push(row); + } + let tree = list + .background(rect(Color::rgb(24, 24, 28))) + .add_strong(rsc) + .any(); + (self.list_ptr)(rsc).set(tree); + } + + /// Selecting a session starts a fresh generation: any thread still + /// working for the previous one checks `Client::current` before + /// touching state, so a slow response for a session the reader has + /// clicked away from is silently dropped rather than overwriting what + /// replaced it. + fn select_session(&mut self, rsc: &mut DefaultRsc, session_id: String) { + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.selected = Some(session_id.clone()); + self.items.clear(); + self.screen = None; + self.rebuild_list(rsc); + self.show_message(rsc, "Loading transcript..."); + + let api = self.api.clone(); + let stream_transport = self.stream_transport.clone(); + let proxy = self.proxy.clone(); + let live_generation = self.generation.clone(); + std::thread::spawn(move || { + // The most recent 200 events, coalesced -- plenty for a + // desktop proof; RUST.md's I3/history-paging work is what a + // real scrollback would reuse, out of scope here (E4 is only + // "the same screen runs in a window"). + let page: Result, String> = api + .fetch_transcript_page(&session_id, None, 200, true) + .map_err(|e| e.to_string()); + // The raw wire `seq` of the last line fetched -- not the seq of + // the last *folded item*. A `TranscriptItem::AssistantMsg` keeps + // the seq of the first delta it accumulated (`fold_event`'s own + // doc: "a row whose identity changed with every delta would be + // a new row every frame"), so resuming the live stream from + // that seq re-delivers every delta already folded into it, + // duplicating the tail of whatever reply was mid-stream when + // the page was fetched. Found by screenshotting a real reply + // through `run-headless.sh`: the assistant's line read "You + // said: ... testsaid: ... test", the back half being deltas 2 + // through N replayed onto an already-complete message. + let after = page + .as_ref() + .ok() + .and_then(|values| raw_seq(values.last()?)) + .unwrap_or(0); + let result = page.and_then(|values| fold_page(&values)); + let _ = proxy.send_event(AppEvent::TranscriptLoaded { + session_id: session_id.clone(), + generation, + result, + }); + + // Follows live from here in the same thread -- sequential + // rather than a second thread, since there is nothing to do + // with the stream until the page above has been sent anyway. + let stop = || live_generation.load(Ordering::SeqCst) != generation; + if stop() { + return; + } + let outcome = + follow_session_events(&*stream_transport, &session_id, after, |item| match item { + StreamItem::Open | StreamItem::Reset => !stop(), + StreamItem::Event { event, .. } => { + if stop() { + return false; + } + let _ = proxy.send_event(AppEvent::StreamEvent { + session_id: session_id.clone(), + generation, + event, + }); + true + } + }); + let _ = proxy.send_event(AppEvent::StreamEnded { + session_id, + generation, + message: outcome.err().map(|e| e.to_string()), + }); + }); + } + + fn send_message(&mut self, session_id: String, text: String) { + let api = self.api.clone(); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + if let Err(e) = api.send_message(&session_id, &text, &[]) { + let _ = proxy.send_event(AppEvent::SendFailed(e.to_string())); + } + }); + } + + fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc) { + let in_progress = self + .screen + .as_ref() + .map(|screen| screen.composer.field.edit(rsc).text.text().to_string()) + .filter(|t| !t.is_empty()); + + let rows = group_tool_runs(&self.items); + let (screen, tree) = transcript_ui::build_tree(rsc, rows); + + if let Some(text) = in_progress { + screen.composer.field.edit(rsc).set(&text); + } + if let Some(session_id) = self.selected.clone() { + let field = screen.composer.field; + rsc.register_event(field, Submit, move |ctx, rsc| { + let text = field.edit(rsc).take(); + let text = text.trim().to_string(); + if !text.is_empty() { + ctx.state.send_message(session_id.clone(), text); + } + }); + } + + (self.transcript_ptr)(rsc).set(tree); + self.screen = Some(screen); + } +} + +/// One row in the session list: title on top, status below, highlighted +/// when it's the one currently shown. +fn session_row( + rsc: &mut DefaultRsc, + session: &SessionSummary, + selected: bool, +) -> StrongWidget { + let bg = if selected { + Color::rgb(58, 90, 138) + } else { + Color::rgb(38, 38, 44) + }; + let id = session.id.clone(); + let label = format!("{}\n{}", session.title, session.status); + wtext(label) + .color(Color::WHITE) + .wrap(true) + .pad(10) + .width(rest(1)) + .background(rect(bg)) + .on( + CursorSense::click(), + move |ctx, rsc: &mut DefaultRsc| { + ctx.state.select_session(rsc, id.clone()); + }, + ) + .add_strong(rsc) + .any() +} + +fn placeholder(rsc: &mut DefaultRsc, message: &str) -> StrongWidget { + wtext(message.to_string()) + .color(Color::WHITE) + .wrap(true) + .pad(16) + .add_strong(rsc) + .any() +} + +/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s +/// `Vec`) into the flat item list `client_core::transcript_fold` +/// works over. A line this build can't parse fails the whole page rather +/// than being skipped -- CODE_RULES's "an enumeration must be able to say +/// 'it broke'" -- since silently dropping one event could hide, say, the +/// user message the composer is about to look like it never sent. +fn fold_page(values: &[serde_json::Value]) -> Result, String> { + let mut items = Vec::new(); + for value in values { + let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| { + format!("the server sent a transcript line this build couldn't parse: {e}") + })?; + items = fold_event(&items, &event); + } + Ok(items) +} + +/// The wire `seq` a raw transcript line carries -- see `select_session`'s +/// comment on why the live-stream cursor has to be this, not a folded +/// item's `seq()`. +fn raw_seq(value: &serde_json::Value) -> Option { + value.get("seq")?.as_u64() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(seq: u64, json: serde_json::Value) -> serde_json::Value { + let mut obj = json; + obj["seq"] = serde_json::json!(seq); + obj["ts"] = serde_json::json!(1.0); + obj + } + + /// The regression for the bug a real `run-headless.sh` screenshot + /// found (see `select_session`'s comment): resuming the live stream + /// from the last *item's* seq re-delivers the deltas already folded + /// into a still-open assistant message, doubling its tail. `raw_seq` + /// of the last wire line must be the true high-water mark instead, + /// which for a run of deltas is higher than every item's own `seq()`. + #[test] + fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() { + let values = vec![ + line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), + line( + 2, + serde_json::json!({"type": "assistantText", "delta": "a"}), + ), + line( + 3, + serde_json::json!({"type": "assistantText", "delta": "b"}), + ), + line( + 4, + serde_json::json!({"type": "assistantText", "delta": "c"}), + ), + ]; + let after = raw_seq(values.last().unwrap()).unwrap(); + assert_eq!(after, 4); + + let items = fold_page(&values).unwrap(); + // The folded item keeps the *first* delta's seq (2), which is + // exactly the value that must not be used as the resume cursor. + let assistant_seq = items + .iter() + .find(|i| matches!(i, TranscriptItem::AssistantMsg { .. })) + .unwrap() + .seq(); + assert_eq!(assistant_seq, 2); + assert_ne!( + after, assistant_seq, + "the fixed bug: these must differ here" + ); + } + + #[test] + fn a_page_folds_into_one_settled_assistant_message() { + let values = vec![ + line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), + line( + 2, + serde_json::json!({"type": "assistantText", "delta": "hel"}), + ), + line( + 3, + serde_json::json!({"type": "assistantText", "delta": "lo"}), + ), + ]; + let items = fold_page(&values).unwrap(); + assert_eq!( + items, + vec![ + TranscriptItem::UserMsg { + seq: 1, + text: "hi".to_string(), + attachments: Vec::new(), + }, + TranscriptItem::AssistantMsg { + seq: 2, + text: "hello".to_string(), + settled: false, + }, + ] + ); + } + + #[test] + fn an_unparseable_line_fails_the_whole_page() { + let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})]; + let err = fold_page(&values).unwrap_err(); + assert!(err.contains("couldn't parse")); + } +} diff --git a/iris/desktop-app/src/config.rs b/iris/desktop-app/src/config.rs new file mode 100644 index 0000000..5a1228f --- /dev/null +++ b/iris/desktop-app/src/config.rs @@ -0,0 +1,134 @@ +//! Where the desktop app keeps the enrollment it should not have to be +//! told about a second time: `client_core::config::EnrolledServer`, +//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`, +//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer +//! token, and the reason `client_core::config`'s own doc comment leaves +//! persistence and file mode to the caller. +//! +//! JSON rather than the project's usual RON: `wg-app-link`'s RON house +//! rules (`format`) are for configs a person hand-edits, and this file +//! never is one -- only this program ever writes or reads it, and +//! `serde_json` is already in the dependency graph through `client-core`, +//! so nothing new is added to reach for it. + +use client_core::config::EnrolledServer; +use std::io; +use std::path::{Path, PathBuf}; + +/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way +/// the XDG basedir spec says to when the variable is unset -- the same +/// fallback `wg_app_link::xdg::config_home` uses, reimplemented here +/// rather than depended on: that helper lives in the `wg-app-link` +/// submodule, which `server/` needs but this desktop-only crate does not, +/// and pulling in a git submodule for one path join would cost more than +/// it saves. +pub fn config_dir() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME").expect("HOME must be set"); + PathBuf::from(home).join(".config") + }); + base.join("ai-app-desktop") +} + +fn enrollment_file(dir: &Path) -> PathBuf { + dir.join("enrollment.json") +} + +/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in +/// the tests below), creating it if needed, and sets the file owner-only -- +/// it carries a bearer token, the same reason `server/`'s own token store +/// is 0600. +pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> { + std::fs::create_dir_all(dir)?; + let path = enrollment_file(dir); + let json = serde_json::to_vec_pretty(server) + .expect("EnrolledServer holds nothing that fails to serialise"); + std::fs::write(&path, json)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// `Ok(None)` when nothing has been enrolled yet, rather than an error -- +/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES' +/// "a deliberate choice is not a problem to report" applies just as well +/// to a file that simply hasn't been written yet). +pub fn load_enrollment_in(dir: &Path) -> io::Result> { + let path = enrollment_file(dir); + match std::fs::read(&path) { + Ok(bytes) => { + let server = serde_json::from_slice(&bytes).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{} is not a valid enrollment ({e})", path.display()), + ) + })?; + Ok(Some(server)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> { + save_enrollment_in(&config_dir(), server) +} + +pub fn load_enrollment() -> io::Result> { + load_enrollment_in(&config_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_saved_enrollment_reads_back_the_same() { + let dir = tempfile::tempdir().unwrap(); + let server = EnrolledServer { + host: "127.0.0.1".to_string(), + port: 8547, + token: "tok".to_string(), + }; + save_enrollment_in(dir.path(), &server).unwrap(); + let read_back = load_enrollment_in(dir.path()).unwrap(); + assert_eq!(read_back, Some(server)); + } + + #[test] + fn nothing_saved_yet_is_none_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(load_enrollment_in(dir.path()).unwrap(), None); + } + + #[test] + #[cfg(unix)] + fn the_saved_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let server = EnrolledServer { + host: "h".to_string(), + port: 1, + token: "t".to_string(), + }; + save_enrollment_in(dir.path(), &server).unwrap(); + let mode = std::fs::metadata(enrollment_file(dir.path())) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn a_corrupt_file_is_named_in_the_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(enrollment_file(dir.path()), b"not json").unwrap(); + let err = load_enrollment_in(dir.path()).unwrap_err(); + assert!(err.to_string().contains("enrollment.json")); + } +} diff --git a/iris/desktop-app/src/main.rs b/iris/desktop-app/src/main.rs new file mode 100644 index 0000000..a9c9cc9 --- /dev/null +++ b/iris/desktop-app/src/main.rs @@ -0,0 +1,95 @@ +//! RUST.md's E4: the transcript screen (`transcript-ui`, I5) in a real +//! winit window on the desktop, with a session list beside it, talking to +//! a real `ai-server` over `client-core`'s REST + SSE clients. See +//! `app.rs`'s module doc for the widget tree and the event flow. +//! +//! Usage: +//! +//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T' +//! desktop-app --ca /path/to/ca.pem # after the first run above +//! +//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a +//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather +//! than scanned, since a desktop has no camera to assume. It is parsed and +//! saved to `config::save_enrollment` once; later runs read it back and +//! `--link` is only needed again to enrol against a different server. The +//! CA is never persisted -- it is a public certificate whose path a +//! caller is expected to already know (`AGENTS.md`'s "prefer exercising +//! the server directly": the same `certs/ca.pem` a `curl --cacert` call +//! uses). + +mod app; +mod config; + +use client_core::config::EnrolledServer; + +struct Args { + ca_path: std::path::PathBuf, + link: Option, +} + +fn parse_args() -> Result { + let mut ca_path = None; + let mut link = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--ca" => { + ca_path = Some(std::path::PathBuf::from( + args.next().ok_or("--ca needs a path")?, + )) + } + "--link" => link = Some(args.next().ok_or("--link needs a value")?), + other => return Err(format!("unrecognised argument '{other}'")), + } + } + Ok(Args { + ca_path: ca_path.ok_or( + "--ca PATH is required (the pinned CA's certificate, e.g. \ + ~/.config/ai-app/certs/ca.pem)", + )?, + link, + }) +} + +/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled +/// server (freshly parsed from `--link`, or read back from last time) and +/// the CA's PEM bytes. Loading is a pure function of the process's own +/// argv and config file, so it is safe to call again from `Client::new` -- +/// see that call site's comment for why it is not threaded through some +/// other way (`DefaultApp::run()` takes no payload). +fn load_startup_config() -> Result<(EnrolledServer, Vec), String> { + let args = parse_args()?; + let server = match args.link { + Some(link) => { + let server = EnrolledServer::parse_link(&link)?; + config::save_enrollment(&server) + .map_err(|e| format!("couldn't save the enrollment: {e}"))?; + server + } + None => config::load_enrollment() + .map_err(|e| format!("couldn't read the saved enrollment: {e}"))? + .ok_or_else(|| { + format!( + "no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \ + once (app/ui-sandbox.sh's start banner prints one)", + config::config_dir().display() + ) + })?, + }; + let ca_pem = std::fs::read(&args.ca_path) + .map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?; + Ok((server, ca_pem)) +} + +fn main() { + // Validated once here so a bad `--ca`/`--link` is reported on stderr + // before any window opens; `Client::new` calls this same function + // again once the window exists, so this first call is a fast-fail + // rather than the only place the values come from. + if let Err(e) = load_startup_config() { + eprintln!("desktop-app: {e}"); + std::process::exit(2); + } + app::run(); +} From ba6817fee5b44fd803e72dda56441d62b8eead46 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 12:55:52 -0400 Subject: [PATCH 4/5] iris: run-headless.sh --bin, for screenshotting a real binary not just an example desktop-app (RUST.md's E4) is a real crate binary a person runs, not a demo under examples/, and it needs its own argv (--ca, --link) to start at all -- neither of which the script had a way to express. --bin swaps `cargo build --example`/`target/debug/examples/NAME` for the `--bin` equivalents; $RUN_HEADLESS_ARGS is word-split into the launched binary's own argv, since no example ever needed one before. Co-Authored-By: Claude Fable 5.1 --- iris/run-headless.sh | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/iris/run-headless.sh b/iris/run-headless.sh index e253b88..3daa305 100755 --- a/iris/run-headless.sh +++ b/iris/run-headless.sh @@ -4,6 +4,16 @@ # ./run-headless.sh tabs [-- cargo args] # ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 # +# `--bin` runs a real crate binary instead of an example (E4's +# `desktop-app`, which is a window a person runs, not a demo) -- +# `cargo build --bin NAME` instead of `--example NAME`, and +# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own +# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s +# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on +# purpose -- an example never needed one, so there was nowhere to plumb it +# through positionally without disturbing the existing `-- cargo args` +# convention above. +# # The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6 # through virgl), so wgpu runs on the host's real GPU -- what is missing is # only a compositor to give winit a surface. So: a headless sway, the same @@ -20,16 +30,18 @@ run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" seconds=3 shot="" example="" +kind=example while [ $# -gt 0 ]; do case "$1" in --shot) shot=$2; shift 2 ;; --seconds) seconds=$2; shift 2 ;; + --bin) kind=bin; shift ;; --) shift; break ;; *) example=$1; shift ;; esac done -[ -n "$example" ] || { echo "usage: $0 EXAMPLE [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } +[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } mkdir -p "$run" export SWAYSOCK="$run/sway.sock" @@ -67,10 +79,17 @@ export WAYLAND_DISPLAY echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 cd "$here" -cargo build --example "$example" "$@" >&2 -bin="$here/target/debug/examples/$example" +if [ "$kind" = bin ]; then + cargo build --bin "$example" "$@" >&2 + bin="$here/target/debug/$example" +else + cargo build --example "$example" "$@" >&2 + bin="$here/target/debug/examples/$example" +fi -"$bin" >"$run/$example.log" 2>&1 & +# shellcheck disable=SC2086 -- deliberately word-split: this is the +# binary's own argv, not a single path. +"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 & pid=$! trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM From b133d8594347cfe33603841f46c8697a6355b809 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 12:55:59 -0400 Subject: [PATCH 5/5] RUST.md, IRIS.md, CLIENT_CORE.md: record E4 done RUST.md: E4 ticked with the screenshot path, the exact commands against app/ui-sandbox.sh, and the streaming-duplication bug the screenshot found; "Where things stand" moved E4 out of "in flight" into its own done bullet. IRIS.md: transcript_ui::build_tree, the public API change transcript-ui gained for this. CLIENT_CORE.md: client_core::config's table row and its correspondence note. Co-Authored-By: Claude Fable 5.1 --- CLIENT_CORE.md | 16 ++++++++ IRIS.md | 22 ++++++++++ RUST.md | 108 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/CLIENT_CORE.md b/CLIENT_CORE.md index 2f98138..bc07d25 100644 --- a/CLIENT_CORE.md +++ b/CLIENT_CORE.md @@ -26,6 +26,7 @@ next (a Masonry or iris transcript screen, most likely). | `api.rs` | `Api.kt` | Partial -- see below | | `event_stream.rs` | `EventStream.kt` | Done | | `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below | +| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below | | *(not started)* | `TranscriptSource.kt` | Not started | | *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below | @@ -103,6 +104,21 @@ deciding how `event_model` itself represents "a shape I don't recognise" -- a shared-model decision affecting `server/` too, not a `client-core`-only fix, so it is recorded here rather than silently worked around. +## `config.rs`: `EnrolledServer` + +`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads +the exact `aiapp://enroll?host=H&port=P&token=T` deep link +`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment` +parses on the phone -- so any Rust client enrols from the same text a +phone would scan as a QR, with no second format invented for it (RUST.md's +E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is +persisted or under what file permissions -- a phone seals its token in the +Android Keystore, `iris/desktop-app/src/config.rs` writes it to +`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is +caller-specific (the code rules' "ask for the least you need"). Its only +caller today is `desktop-app`; a future Android build of this crate would +be a second one, not a reason to move the type. + ## What is not started at all - **`TranscriptSource.kt`** -- the layer that decides whether a page comes diff --git a/IRIS.md b/IRIS.md index 677e8a7..db6c377 100644 --- a/IRIS.md +++ b/IRIS.md @@ -8,6 +8,28 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4) + +`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`) +as its last step, which is right for a window that *is* the transcript +screen (the winit example, an eventual Android cdylib) and wrong for the +desktop app, which puts a session list beside it. `build_tree` is `build` +minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead +of just `TranscriptScreen`, and the caller decides where the tree goes — +into `ui_state.set_root`, or into a `WidgetPtr` alongside something else +(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling +`build_tree` and doing the `set_root` itself, so existing callers are +unaffected. + +```rust +// before, and still available, for a caller that wants to *be* the window: +let screen = transcript_ui::build(rsc, &mut ui_state, rows); + +// new, for a caller embedding the screen beside something else: +let (screen, tree) = transcript_ui::build_tree(rsc, rows); +some_widget_ptr(rsc).set(tree); +``` + ## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5) A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size, diff --git a/RUST.md b/RUST.md index 76078b0..3590b81 100644 --- a/RUST.md +++ b/RUST.md @@ -37,13 +37,18 @@ session spending an afternoon on them again. ## Where things stand (2026-09-05) - **In flight, 2026-09-05 (session cleared mid-work, picked up again):** - (a) the I5 touch-drag pan-vs-select gap, as a `DragArbiter` in - `iris/src/sense.rs` wired into `transcript-ui`'s selection; (b) E4 as a - new `iris/desktop-app` crate on winit, embedding `transcript-ui` via - `build_tree` beside a session list, with `client-core::config` holding the - enrolment. Design choices for both are summarised in `DECISIONS.md` at - the repo root, which is the file Iris reads for choices made without her. - Next after those: I5's Android integration and the bench numbers. + the I5 touch-drag pan-vs-select gap, as a `DragArbiter` in + `iris/src/sense.rs` wired into `transcript-ui`'s selection. Design + choices are summarised in `DECISIONS.md` at the repo root, which is the + file Iris reads for choices made without her. Next after it: I5's + Android integration and the bench numbers. +- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a + session list beside `transcript-ui`'s screen (`build_tree`), against a + real `ai-server` through `client-core`, enrolled from the same + `aiapp://enroll?...` link a phone scans. Both pass conditions held on + `app/ui-sandbox.sh` -- see E4's own box for the commands, the + screenshot, and a real streaming-duplication bug the screenshot found + and a regression test now covers. - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the keyboard gap — now explained, see below), E2 (a transcript in Masonry, which found that Masonry has no touch-scroll on Android at all — see @@ -1299,8 +1304,93 @@ accepted. errors."}` followed by the echo driver's reply -- the share reached the most-recently-active session as a real message, not a mock. -- [ ] **E4 — the same screen on the desktop** in a winit window, from the - same crate, with only the layout differing. +- [x] **E4 — the same screen on the desktop (2026-09-05).** A new + `iris/desktop-app` crate (added to the `iris` workspace's members, not + excluded the way `android-app` is -- nothing here needs the NDK): + a real winit window showing a session list (`iris::widget::Span`, + rebuilt on selection) beside `transcript-ui`'s screen + (`transcript_ui::build_tree`, new this box -- see IRIS.md's + 2026-09-05 entry), talking to a real `ai-server` through + `client-core`'s `ApiClient`/`UreqTransport`/`follow_session_events`. + Enrolment is `client_core::config::EnrolledServer::parse_link` + against the same `aiapp://enroll?host=H&port=P&token=T` link a phone + scans, pasted via `--link` and persisted at + `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` (0600 -- + `iris/desktop-app/src/config.rs`); the pinned CA is a `--ca PATH` + argument, never baked in (DECISIONS.md, 2026-09-05). + + *Both pass-condition proofs held, against `app/ui-sandbox.sh`'s real + server.* (1) The list showed the sandbox's spawned session + ("Demo session", its live status); selecting it loaded the real + transcript and the composer's `Submit` posted a message whose reply + streamed in live over SSE, both proved by two `run-headless.sh` + screenshots taken seconds apart around a real `./ui-sandbox.sh send` + -- the second showed the new turn appended under the first with + nothing duplicated or lost. (2) Screenshotted headless: + `/tmp/iris_e4_desktop.png` (1920x1200, 15.9 KB, the real first-run + state -- list populated, "Select a session." on the right, nothing + selected yet). `run-headless.sh` gained a `--bin` flag for this + (`cargo build --bin NAME` + `target/debug/NAME` instead of the + `--example` path, since `desktop-app` is a real binary a person + runs, not a demo) and `$RUN_HEADLESS_ARGS`, word-split into the + launched binary's own argv (a real CLI's flags, which no example + needed a way to pass before). Exact commands, from `iris/`: + + TOKEN=$(cat "${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token") + LINK="aiapp://enroll?host=127.0.0.1&port=&token=$(python3 -c \ + 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$TOKEN")" + CA="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem" + RUN_HEADLESS_ARGS="--ca $CA --link $LINK" \ + ./run-headless.sh desktop-app --bin --shot /tmp/iris_e4_desktop.png -- -p desktop-app + + **A real bug this screenshot found, not a synthetic one**: the first + attempt resumed the live SSE stream from + `items.iter().map(TranscriptItem::seq).max()` -- the *folded* item's + seq, which for a still-open `AssistantMsg` is the seq of its + *first* delta by design (`fold_event`'s own doc comment: "a row + whose identity changed with every delta would be a new row every + frame"). Resuming from there re-delivered every delta already + folded into that message, and the screenshot showed the assistant's + reply with its own tail duplicated ("You said: ... testsaid: ... + test"). Fixed by computing the resume cursor from the raw wire + `seq` of the last fetched line (`app.rs`'s `raw_seq`) instead of + from any folded item -- regression test + `the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq` in + `iris/desktop-app/src/app.rs`. Exactly the class of bug CODE_RULES + warns about under "a fix tried only on what it was meant to fix": + the bare REST fetch (no live stream yet) looked perfect on its own, + and only *resuming* a stream after it exposed the seam. + + **Deliberately left simple, and why** (`app.rs`'s module doc has the + full account): every incoming SSE event refolds the session's whole + item list and rebuilds the entire right-hand widget tree from + scratch, rather than reaching for `TranscriptScreen::push_row`'s + incremental append -- `push_row` can only add a new row, and a + streaming reply is exactly a row whose text keeps changing after it + first appears. Fine at the size a desktop session's conversation + is; wrong for a long, fast-streaming one, and the real fix needs + `transcript-ui` to expose updating a row already on screen, which it + does not yet. The composer's in-progress text is saved and restored + across a rebuild so a reply streaming in while the reader is typing + a followup doesn't erase it. No history paging (I3's job, reused + as-is if this becomes permanent) and no scroll-position preservation + across a rebuild -- both named rather than silently missing. + Background network I/O runs on plain `std::thread`s reporting back + through winit's `EventLoopProxy` rather than iris's own + `Tasks`/`task_on`, because `Tasks` only requests a redraw once after + its whole async closure finishes, which fits "one request, one + update" and not a live stream that needs a redraw after *each* + event it relays. + + Verification: `cargo fmt --all`, `cargo clippy --workspace + --all-targets` (zero warnings), `cargo test --workspace` from + `iris/` (7 new tests in `desktop-app` -- 4 for + `config.rs`'s save/load/permissions/corruption, 3 for `app.rs`'s + transcript folding and the resume-cursor regression above -- plus + the existing 37 unchanged), and `./run-tests.sh` at the repo root + (127 passing, `client-core` alone 93 -- the `EnrolledServer` parsing + tests already existed before this box). Android is untouched by + this step, as asked. - [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition proofs held on this checkout's own emulator: `adb install -r` of the xtask-built APK over the Gradle-built one succeeded, and the