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