Merge branch 'worktree-agent-afe80868604fef704' into tmp-merge
This commit is contained in:
commit
45ced405f3
13 files changed
+1154
-24
No files matched your search
@@ -26,6 +26,7 @@ next (a Masonry or iris transcript screen, most likely).
|
|||||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
| `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 started)* | `TranscriptSource.kt` | Not started |
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
| *(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
|
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||||
fix, so it is recorded here rather than silently worked around.
|
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
|
## What is not started at all
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ 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
|
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.
|
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: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
||||||
|
|
||||||
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
||||||
|
|||||||
@@ -36,16 +36,20 @@ session spending an afternoon on them again.
|
|||||||
|
|
||||||
## Where things stand (2026-09-05)
|
## Where things stand (2026-09-05)
|
||||||
|
|
||||||
- **In flight, 2026-09-05 (session cleared mid-work, picked up again):**
|
- **Both of the previous note's in-flight pieces are now done, 2026-09-05.**
|
||||||
E4 as a new `iris/desktop-app` crate on winit, embedding `transcript-ui`
|
Design choices for both are summarised in `DECISIONS.md` at the repo
|
||||||
via `build_tree` beside a session list, with `client-core::config`
|
root, which is the file Iris reads for choices made without her. Next:
|
||||||
holding the enrolment. The other half of this note, (a) the I5
|
I5's Android integration and the bench numbers.
|
||||||
touch-drag pan-vs-select gap as a `DragArbiter` in `iris/src/sense.rs`
|
- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a
|
||||||
wired into `transcript-ui`'s selection, is now **done** — see I5's own
|
session list beside `transcript-ui`'s screen (`build_tree`), against a
|
||||||
box below, "Gap closed, 2026-09-05". Design choices are summarised in
|
real `ai-server` through `client-core`, enrolled from the same
|
||||||
`DECISIONS.md` at the repo root, which is the file Iris reads for
|
`aiapp://enroll?...` link a phone scans. Both pass conditions held on
|
||||||
choices made without her. Next after E4: I5's Android integration and
|
`app/ui-sandbox.sh` -- see E4's own box for the commands, the
|
||||||
the bench numbers.
|
screenshot, and a real streaming-duplication bug the screenshot found
|
||||||
|
and a regression test now covers.
|
||||||
|
- **The I5 touch-drag pan-vs-select gap is closed, 2026-09-05**, as a
|
||||||
|
`DragArbiter` in `iris/src/sense.rs` wired into `transcript-ui`'s
|
||||||
|
selection -- see I5's own box below, "Gap closed, 2026-09-05".
|
||||||
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
||||||
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
|
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
|
||||||
which found that Masonry has no touch-scroll on Android at all — see
|
which found that Masonry has no touch-scroll on Android at all — see
|
||||||
@@ -1304,8 +1308,93 @@ accepted.
|
|||||||
errors."}` followed by the echo driver's reply -- the share
|
errors."}` followed by the echo driver's reply -- the share
|
||||||
reached the most-recently-active session as a real message, not
|
reached the most-recently-active session as a real message, not
|
||||||
a mock.
|
a mock.
|
||||||
- [ ] **E4 — the same screen on the desktop** in a winit window, from the
|
- [x] **E4 — the same screen on the desktop (2026-09-05).** A new
|
||||||
same crate, with only the layout differing.
|
`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=<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<AppEvent>` 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
|
- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition
|
||||||
proofs held on this checkout's own emulator: `adb install -r` of the
|
proofs held on this checkout's own emulator: `adb install -r` of the
|
||||||
xtask-built APK over the Gradle-built one succeeded, and the
|
xtask-built APK over the Gradle-built one succeeded, and the
|
||||||
|
|||||||
@@ -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/<app>/`
|
||||||
|
//! 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<Self, String> {
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|||||||
Generated
+13
@@ -922,6 +922,19 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "desktop-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ name = "message_list"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[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
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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<AppEvent>`), 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<Vec<SessionSummary>, String>),
|
||||||
|
TranscriptLoaded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
result: Result<Vec<TranscriptItem>, String>,
|
||||||
|
},
|
||||||
|
StreamEvent {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
event: SeqEvent,
|
||||||
|
},
|
||||||
|
StreamEnded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
SendFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
api: Arc<ApiClient<UreqTransport>>,
|
||||||
|
/// 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<UreqTransport>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
sessions: Vec<SessionSummary>,
|
||||||
|
selected: Option<String>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
list_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
/// Bumped every time the selected session changes; see `AppEvent`'s
|
||||||
|
/// doc for what it guards against.
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
type Event = AppEvent;
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
) -> 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<Self>, _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<Self>, 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<Self>) {
|
||||||
|
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<Self>, 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<Vec<serde_json::Value>, 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<Self>) {
|
||||||
|
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<Client>,
|
||||||
|
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<Client>| {
|
||||||
|
ctx.state.select_session(rsc, id.clone());
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder(rsc: &mut DefaultRsc<Client>, 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<Value>`) 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<Vec<TranscriptItem>, 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<u64> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Option<EnrolledServer>> {
|
||||||
|
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<Option<EnrolledServer>> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
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<u8>), 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();
|
||||||
|
}
|
||||||
+23
-4
@@ -4,6 +4,16 @@
|
|||||||
# ./run-headless.sh tabs [-- cargo args]
|
# ./run-headless.sh tabs [-- cargo args]
|
||||||
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
# ./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
|
# 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
|
# 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
|
# 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
|
seconds=3
|
||||||
shot=""
|
shot=""
|
||||||
example=""
|
example=""
|
||||||
|
kind=example
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--shot) shot=$2; shift 2 ;;
|
--shot) shot=$2; shift 2 ;;
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
|
--bin) kind=bin; shift ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
*) example=$1; shift ;;
|
*) example=$1; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
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"
|
mkdir -p "$run"
|
||||||
export SWAYSOCK="$run/sway.sock"
|
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
|
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
||||||
|
|
||||||
cd "$here"
|
cd "$here"
|
||||||
cargo build --example "$example" "$@" >&2
|
if [ "$kind" = bin ]; then
|
||||||
bin="$here/target/debug/examples/$example"
|
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=$!
|
pid=$!
|
||||||
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ pub fn build<Rsc: HasEvents>(
|
|||||||
ui_state: &mut impl HasRoot,
|
ui_state: &mut impl HasRoot,
|
||||||
rows: Vec<FoldedRow>,
|
rows: Vec<FoldedRow>,
|
||||||
) -> TranscriptScreen
|
) -> 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: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost,
|
||||||
{
|
{
|
||||||
@@ -110,13 +129,17 @@ where
|
|||||||
|
|
||||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
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)
|
.span(Dir::DOWN)
|
||||||
.set_root(rsc, ui_state);
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
|
||||||
TranscriptScreen {
|
(
|
||||||
list,
|
TranscriptScreen {
|
||||||
composer,
|
list,
|
||||||
selection,
|
composer,
|
||||||
}
|
selection,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user