7 Commits
Author SHA1 Message Date
iris 62199aa3a7 Merge remote-tracking branch 'origin/rustify' into worktree-agent-afe80868604fef704
# Conflicts:
#	IRIS.md
#	RUST.md
2026-09-05 12:59:39 -04:00
irisandClaude Fable 5.1 b133d85943 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 <noreply@anthropic.com>
2026-09-05 12:55:59 -04:00
irisandClaude Fable 5.1 ba6817fee5 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 <noreply@anthropic.com>
2026-09-05 12:55:52 -04:00
irisandClaude Fable 5.1 73ee63bc1b 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 <noreply@anthropic.com>
2026-09-05 12:55:41 -04:00
irisandClaude Fable 5.1 8f0aec449a 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 <noreply@anthropic.com>
2026-09-05 12:55:29 -04:00
irisandClaude Fable 5.1 6d5fd64bb0 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 <noreply@anthropic.com>
2026-09-05 12:55:22 -04:00
irisandClaude Fable 5.1 e5880c33f4 iris: DragArbiter closes I5's touch-drag pan-vs-select gap
A row's own click_or_drag() selection handler always won the same
gesture a list-level pan wanted, since run_sensors gives the inner
layer first refusal every frame it's pressed. DragArbiter
(iris/src/sense.rs) decides pan vs. select the way Android does:
vertical drag pans immediately, a held stationary press starts a
selection after LONG_PRESS, and a horizontal drag on already-selected
text extends immediately. transcript-ui's Selection::drag routes
every row's drag through one arbiter per list, driving List::scroll
for a pan instead of a second scroll mechanism.

8 new unit tests (iris::sense::drag_arbiter_tests); cargo
fmt/clippy/test --workspace and cargo ndk (iris, transcript-ui) all
clean; run-headless.sh screenshot byte-identical to before the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:26:54 -04:00
17 changed files with 1627 additions and 90 deletions

No files matched your search

+16
View File
@@ -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
+65
View File
@@ -8,6 +8,71 @@ 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: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
New public type, `iris::sense::DragArbiter`. Why: a widget author who
registers both a list-level pan and a row-level drag-to-select on the same
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
`run_sensors` always gives the innermost layer first refusal, so the inner
one wins every frame it is pressed, not just the frame the press started
(this is exactly what left transcript-ui's touch-drag panning unreachable
until now). `DragArbiter` is one small state machine, one instance per
gesture surface (a whole list, not per row), that a caller drives with its
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
(so it is unit-testable without a real clock or a render harness). It
decides the way Android itself does: an ordinary vertical drag pans
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
selection, which any further drag then extends; a horizontal drag while
something is already selected extends it immediately, skipping the wait.
```rust
// One per list, held alongside whatever state coordinates the rows:
let mut arbiter = DragArbiter::new();
// On press-down:
arbiter.press_start(pos, Instant::now(), already_selected);
// Every frame the button/finger stays down:
match arbiter.update(pos, Instant::now()) {
DragOutcome::Pan(dy) => list.scroll(-dy),
DragOutcome::SelectStart => selection.begin(...),
DragOutcome::SelectExtend => selection.extend(...),
DragOutcome::Undecided => {}
}
// On release:
arbiter.release();
```
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
the reference caller: every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler routes through one `Selection`-owned
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
on a row's own rendered text now pans the list correctly instead of
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
`drag_arbiter_tests` module.
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
+19 -11
View File
@@ -184,17 +184,25 @@ order and what "done" looks like. Tick and date them in place.
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it.
- [ ] **Touch-drag panning over a row's own rendered text.** `row.rs`
registers `CursorSense::click_or_drag()` on each row's `TextEdit` for
cross-row selection; `TextEdit::draw`'s `painter.child_layer()`
(`iris/src/widget/text/edit.rs:87`) means that registration wins
`core/src/sense.rs::run_sensors`'s per-layer arbitration on every
frame it is pressed, not just the frame the press started, so a list
pan gesture registered on `List` itself never gets a turn while a
row is under the finger. Fix: a small press distance/time arbiter
deciding pan vs. select before either commits, or gate text-drag-
selection behind a long-press so an ordinary swipe always pans first.
`lib.rs`'s module doc has the full diagnosis.
- [x] **Touch-drag panning over a row's own rendered text — done,
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
registration won `core/src/sense.rs::run_sensors`'s per-layer
arbitration on every frame it was pressed, not just the frame the
press started, so a list pan gesture registered on `List` itself never
got a turn while a row was under the finger. Fixed with
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
machine per list deciding pan vs. select the way Android does (a
vertical drag pans immediately; a stationary press held `LONG_PRESS`
(500ms) starts a selection which further drag extends; a horizontal
drag while something is already selected extends immediately) —
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
every row's drag now routes through. 8 new unit tests
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
clean; `run-headless.sh` screenshot byte-identical to before the
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
+171 -36
View File
@@ -36,14 +36,20 @@ 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.
- **Both of the previous note's in-flight pieces are now done, 2026-09-05.**
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:
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.
- **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
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
which found that Masonry has no touch-scroll on Android at all — see
@@ -85,11 +91,14 @@ session spending an afternoon on them again.
Compose baseline, `ui-trace` tap-by-name on a row) — `emu list` showed
the one emulator here held by another session, but the real blocker is
that the integration work itself is unbuilt, not the emulator being
busy. Touch-drag panning over a row's own rendered text is also not yet
reachable, for a specific, diagnosed reason (it competes with this box's
own row-level drag-select for the same gesture) rather than an absent
primitive. Full accounting, every citation, and the dated
IRIS_TODO.md items are in I5's own box below.
busy. Full accounting, every citation, and the dated IRIS_TODO.md items
are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag
panning over a row's own rendered text, which was not yet reachable for
a specific, diagnosed reason (it competed with this box's own
row-level drag-select for the same gesture, not an absent primitive),
is now closed — a `DragArbiter` in `iris/src/sense.rs`, wired into
`transcript-ui`'s selection — see the box's "Gap closed" note. Android
integration is the one item left before this box can tick `[x]`.
- **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo**
(`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
Gradle module `app/shellApp/`, left deliberately separate from
@@ -1299,8 +1308,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=<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
proofs held on this checkout's own emulator: `adb install -r` of the
xtask-built APK over the Gradle-built one succeeded, and the
@@ -2267,26 +2361,68 @@ silently on real hardware.
include it) -- a small, real gap, recorded as an IRIS_TODO.md
item rather than silently left, since AGENTS.md's bench scripts
depend on exactly this for driving a screen by name.
7. **Measurable frames / the render-number pass condition -- not
attempted, and unlike E2 the reason is not an absent gesture
path.** `List` demonstrably scrolls (I3's flat draws/moves,
programmatic `scroll()`) and mouse-wheel scrolling is wired here
(`lib.rs`'s `CursorSense::Scroll` on `list`). What is *not*
reachable yet is a **touch-drag pan starting on a row's own
text**: `row.rs` registers `CursorSense::click_or_drag()` on each
row's `TextEdit` for selection, and `TextEdit::draw` calls
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`), so
`core/src/sense.rs`'s `run_sensors` (which stops at the first
layer, checked innermost-first, that consumed the gesture) gives
that row first refusal on *every* frame it is pressed, not just
the frame the press started -- a row's drag-select wins the same
gesture a list-level pan would want. `lib.rs`'s own module doc
states this precisely, with the fix named (a press distance/time
arbiter deciding pan vs. select before either commits, or gating
text-drag-selection behind a long-press). This is a genuine,
diagnosed architecture gap this box's *own* two features created
by both wanting the same gesture -- not a missing primitive the
way Masonry's absent `on_pointer_event` drag handling was.
7. **Measurable frames / the render-number pass condition -- the
gesture-conflict half is now fixed (2026-09-05); the emulator
half is still not attempted, and unlike E2 that's not an absent
gesture path.** `List` demonstrably scrolls (I3's flat
draws/moves, programmatic `scroll()`) and mouse-wheel scrolling
is wired here (`lib.rs`'s `CursorSense::Scroll` on `list`). What
was *not* reachable at first was a **touch-drag pan starting on a
row's own text**: `row.rs` registered `CursorSense::
click_or_drag()` on each row's `TextEdit` for selection, and
`TextEdit::draw` calls `painter.child_layer()`
(`iris/src/widget/text/edit.rs:87`), so `core/src/sense.rs`'s
`run_sensors` (which stops at the first layer, checked
innermost-first, that consumed the gesture) gave that row first
refusal on *every* frame it was pressed, not just the frame the
press started -- a row's drag-select won the same gesture a
list-level pan would want. This is a genuine, diagnosed
architecture gap this box's *own* two features created by both
wanting the same gesture -- not a missing primitive the way
Masonry's absent `on_pointer_event` drag handling was.
**Gap closed, 2026-09-05, same day.** `iris::sense::DragArbiter`
(`iris/src/sense.rs`, new public type, recorded in `IRIS.md`) is
one small state machine, one instance per gesture surface (a
whole list, not per row), driven with a caller-supplied `Instant`
so it needs no render harness to test. It decides the way
Android itself does, recorded in `DECISIONS.md`: an ordinary
vertical drag pans immediately; a stationary press held
`LONG_PRESS` (500ms) starts a selection, which any further drag
then extends; a horizontal drag while something is already
selected extends it immediately, skipping the wait.
`transcript-ui/src/selection.rs`'s new `Selection::drag` is the
one place every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler now goes through (`row.rs`,
`build_text_row`), replacing the direct `begin`/`extend` calls
each row used to make on its own -- one arbiter shared across
every row is what keeps the decision consistent as a drag
crosses row boundaries, per `DragArbiter`'s own doc. `Pan(dy)`
calls the list's own `List::scroll` (the same method I3's
mouse-wheel handler and its own benchmark already use), so this
is not a second scroll mechanism. 8 new unit tests in
`iris/src/sense.rs`'s `drag_arbiter_tests` (vertical drag pans
immediately and keeps panning by per-frame delta; small jitter
under `DRAG_SLOP` stays undecided; a held press starts a
selection after `LONG_PRESS` and further drag extends it, even
vertical drag, once selecting; a horizontal drag with nothing yet
selected stays undecided rather than guessing; a horizontal drag
with something already selected extends immediately; a vertical
drag still pans even with a prior selection; release resets to
idle). Verification: `cargo fmt --all -- --check`, `cargo clippy
--workspace --all-targets` (zero warnings), `cargo test
--workspace` (28 pre-existing + 9 `transcript-ui` + **8 new**
`drag_arbiter_tests`, all passing), `cargo ndk -t x86_64 -P 26
build/clippy` for both `-p iris` and `-p transcript-ui --lib`
(clean), and `run-headless.sh transcript --shot ... -- -p
transcript-ui` -- byte-identical to this box's original
screenshot (38578 bytes, `cmp` confirms identical), confirming no
visual regression from the rewiring. **What this did not
attempt**: the emulator-side confirmation (a real touch swipe
over a row's text panning on-device) -- that still needs I5's own
Android integration, the one item named just above and in "What
remains" below; this pass only had the winit/host-side gesture
path to drive, since no cdylib exists yet for this screen.
**Verification, exact commands and results (2026-09-05, this VM):**
@@ -2345,7 +2481,6 @@ silently on real hardware.
own right -- closer in size to E2/E3 than to "run one more
script" -- not something this pass's remaining time could
responsibly rush and still report honestly.
- **Touch-drag panning over a row's own text** -- behaviour 7 above.
- **Row-level accessibility names** -- behaviour 6 above.
- **A tappable link and a code-span background chip** -- behaviour 2.
- **`Selection`'s anchor-row shortcut** -- behaviour 1.
+152
View File
@@ -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}"
);
}
}
+1
View File
@@ -4,6 +4,7 @@
pub mod ansi;
pub mod api;
pub mod config;
pub mod event_stream;
pub mod highlight;
pub mod notifications;
+13
View File
@@ -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"
+1 -1
View File
@@ -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
+27
View File
@@ -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"
+538
View File
@@ -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"));
}
}
+134
View File
@@ -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"));
}
}
+95
View File
@@ -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
View File
@@ -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
+239
View File
@@ -2,6 +2,7 @@ use crate::prelude::*;
use std::{
ops::{BitOr, Deref, DerefMut},
rc::Rc,
time::{Duration, Instant},
};
#[derive(Clone, Copy, PartialEq)]
@@ -357,3 +358,241 @@ impl BitOr<CursorSense> for CursorSenses {
self
}
}
/// How long a stationary press has to be held before it is treated as a
/// long-press rather than the start of a pan.
pub const LONG_PRESS: Duration = Duration::from_millis(500);
/// How far a press has to move, in pixels, before it counts as a drag
/// rather than jitter -- for both the pan-vs-select axis test and the
/// "did this actually move" long-press guard.
pub const DRAG_SLOP: f32 = 8.0;
/// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided`
/// means neither a pan nor a selection has committed yet, so the caller
/// should do nothing observable this frame.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DragOutcome {
Undecided,
/// Scroll the enclosing list by this many window-space pixels along
/// the drag axis (the delta since the arbiter's last decided frame).
Pan(f32),
/// A selection should begin at the arbiter's press origin.
SelectStart,
/// A selection already underway should extend to the current position.
SelectExtend,
}
#[derive(Clone, Copy, PartialEq)]
enum ArbiterState {
Idle,
Undecided { already_selected: bool },
Panning,
Selecting,
}
/// Decides, one shared instance per gesture surface (a transcript's whole
/// row list here), whether a touch drag that starts on a row's own
/// selectable text is panning the list or extending a text selection --
/// RUST.md's I5 finding that both wanted the same `CursorSense::
/// click_or_drag()` gesture, with the inner text layer winning every frame
/// regardless of which one the reader meant. Decided the way Android
/// itself decides it, so a reader's existing muscle memory carries over:
///
/// - An ordinary vertical drag pans -- checked first, and immediately,
/// so a swipe never waits on the long-press timer.
/// - A stationary press held past [`LONG_PRESS`] starts a selection.
/// Every drag frame after that extends it, whichever direction it goes.
/// - A drag that starts **horizontally** while something is already
/// selected extends that selection right away, skipping the long-press
/// wait -- the "drag the selection handle" gesture a reader reaches for
/// once text is already highlighted.
///
/// Pure state, no rendering or widget access, so it is unit-testable
/// exactly like the rest of this module (`sense_tests.rs`'s style) with a
/// caller-supplied `Instant` rather than a real clock.
pub struct DragArbiter {
state: ArbiterState,
origin: Vec2,
origin_at: Instant,
last: Vec2,
}
impl Default for DragArbiter {
fn default() -> Self {
Self {
state: ArbiterState::Idle,
origin: Vec2::ZERO,
origin_at: Instant::now(),
last: Vec2::ZERO,
}
}
}
impl DragArbiter {
pub fn new() -> Self {
Self::default()
}
/// A fresh press-down at `pos`. `already_selected` is whatever the
/// caller's selection state was *before* this press -- it decides
/// whether an early horizontal move extends that selection instead of
/// waiting for a long-press.
pub fn press_start(&mut self, pos: Vec2, now: Instant, already_selected: bool) {
self.origin = pos;
self.origin_at = now;
self.last = pos;
self.state = ArbiterState::Undecided { already_selected };
}
/// The press continues (still down) at `pos`. Call once per frame
/// while the button/finger is down; returns what this frame means.
pub fn update(&mut self, pos: Vec2, now: Instant) -> DragOutcome {
match self.state {
ArbiterState::Idle => DragOutcome::Undecided,
ArbiterState::Panning => {
let dy = pos.y - self.last.y;
self.last = pos;
DragOutcome::Pan(dy)
}
ArbiterState::Selecting => {
self.last = pos;
DragOutcome::SelectExtend
}
ArbiterState::Undecided { already_selected } => {
let dx = pos.x - self.origin.x;
let dy = pos.y - self.origin.y;
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() {
self.state = ArbiterState::Selecting;
self.last = pos;
DragOutcome::SelectExtend
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
self.state = ArbiterState::Panning;
self.last = pos;
DragOutcome::Pan(dy)
} else if now.duration_since(self.origin_at) >= LONG_PRESS
&& dx.abs() <= DRAG_SLOP
&& dy.abs() <= DRAG_SLOP
{
self.state = ArbiterState::Selecting;
self.last = pos;
DragOutcome::SelectStart
} else {
DragOutcome::Undecided
}
}
}
}
/// The press was released -- back to idle for the next one.
pub fn release(&mut self) {
self.state = ArbiterState::Idle;
}
}
#[cfg(test)]
mod drag_arbiter_tests {
use super::*;
fn t(ms: u64) -> Instant {
// A fixed base plus an offset, rather than `Instant::now()` per
// call -- keeps every test's timing deterministic instead of at
// the mercy of how long the test itself took to run.
Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms)
}
#[test]
fn small_jitter_stays_undecided() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided);
}
#[test]
fn a_vertical_drag_pans_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
);
// Subsequent frames keep panning, by the delta since last frame.
assert_eq!(
a.update(Vec2::new(0.0, 35.0), t(20)),
DragOutcome::Pan(15.0)
);
}
#[test]
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
// Horizontal movement alone, with no prior selection, is not any
// of the three named gestures -- it stays undecided rather than
// guessing (it will resolve to a long-press-selection if the
// finger then stops moving, or nothing if it lifts).
assert_eq!(
a.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Undecided
);
}
#[test]
fn a_long_press_without_moving_starts_a_selection() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(5.0, 5.0), t(0), false);
assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided);
assert_eq!(
a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)),
DragOutcome::SelectStart
);
}
#[test]
fn after_a_long_press_any_further_drag_extends() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)),
DragOutcome::SelectStart
);
// Even a vertical move now extends the selection rather than
// panning -- once a selection has started, it owns the gesture
// until release.
assert_eq!(
a.update(Vec2::new(0.0, 40.0), t(600)),
DragOutcome::SelectExtend
);
}
#[test]
fn a_horizontal_drag_on_already_selected_text_extends_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
assert_eq!(
a.update(Vec2::new(20.0, 2.0), t(10)),
DragOutcome::SelectExtend
);
}
#[test]
fn a_vertical_drag_still_pans_even_with_a_prior_selection() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
);
}
#[test]
fn release_resets_to_idle() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.update(Vec2::new(0.0, 20.0), t(10));
a.release();
assert_eq!(
a.update(Vec2::new(0.0, 999.0), t(20)),
DragOutcome::Undecided
);
}
}
+40 -18
View File
@@ -28,21 +28,20 @@
//! also knew how to make an HTTPS request would be untestable without a
//! server and unable to be driven by `run-headless.sh` with synthetic rows.
//!
//! **Known gap, real and diagnosed rather than untested**: a touch-drag
//! that starts on a row's rendered text currently begins a cross-row
//! *selection* (`row.rs`'s `CursorSense::click_or_drag()` on each row's
//! `TextEdit`), not a *scroll* of the list -- both want the same gesture
//! over the same screen region, and `core/src/sense.rs`'s `run_sensors`
//! gives the widget that registered it in the *inner* layer (a row's own
//! `TextEdit`, which calls `painter.child_layer()`,
//! `iris/src/widget/text/edit.rs:87`) first refusal every frame it is
//! pressed, not just the frame the press started. `List` itself scrolls
//! correctly when driven programmatically (I3's benchmark) and via the
//! mouse wheel (wired below, `CursorSense::Scroll`), but a real touch pan
//! starting on top of a message's own text is not currently reachable --
//! see IRIS_TODO.md's dated entry and RUST.md's I5 box for what a fix
//! looks like (a press distance/time arbiter deciding pan vs. select
//! before either commits).
//! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's
//! rendered text used to always begin a cross-row *selection* (`row.rs`'s
//! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a
//! *scroll* of the list, because both wanted the same gesture over the
//! same screen region and `core/src/sense.rs`'s `run_sensors` gave the
//! widget in the *inner* layer (a row's own `TextEdit`) first refusal
//! every frame it was pressed. `row.rs` now routes every row's drag
//! through one shared `iris::sense::DragArbiter`
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
//! way Android itself does -- see `DragArbiter`'s own doc and
//! `DECISIONS.md` for the exact rule. `List` scrolls correctly when
//! driven programmatically (I3's benchmark), via the mouse wheel (wired
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
//! row's own text too.
pub mod composer;
pub mod markdown;
@@ -88,6 +87,25 @@ pub fn build<Rsc: HasEvents>(
ui_state: &mut impl HasRoot,
rows: Vec<FoldedRow>,
) -> 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
Rsc::State: FocusHost,
{
@@ -111,13 +129,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,
}
},
tree,
)
}
+31 -19
View File
@@ -20,7 +20,7 @@ use crate::markdown::render_markdown;
use crate::selection::Selection;
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
use std::{cell::RefCell, rc::Rc, time::Instant};
/// The paragraph size every row's `TextEdit` is built at; markdown headings
/// inside a row scale relative to a fixed set of sizes rather than this one
@@ -100,11 +100,13 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
/// Build one `TextEdit` from a sender label plus markdown source, register
/// it with `selection` under `key`, and wire the pointer handlers that
/// drive `Selection::begin`/`extend` -- shared by every row variant below,
/// since a selectable row is always "one TextEdit plus this wiring"
/// regardless of what folded it.
/// drive `Selection::drag` -- shared by every row variant below, since a
/// selectable row is always "one TextEdit plus this wiring" regardless of
/// what folded it. `list` is threaded through so that same drag can pan
/// the list instead of selecting, per `Selection::drag`'s own doc.
fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
@@ -125,18 +127,24 @@ where
selection.borrow_mut().register(key, field);
field
.on(CursorSense::click_or_drag(), move |ctx, rsc| {
let sel = selection.clone();
match ctx.data.sense {
CursorSense::PressStart(_) => {
sel.borrow_mut()
.begin(rsc, key, ctx.data.pos, ctx.data.size)
}
_ => sel
.borrow_mut()
.extend(rsc, key, ctx.data.pos, ctx.data.size),
}
})
// `| CursorSense::unclick()` on top of the usual click-or-drag set
// -- the arbiter inside `Selection::drag` needs the release too,
// to go back to idle for the next press (`DragArbiter::release`).
.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| {
selection.borrow_mut().drag(
rsc,
list,
key,
ctx.data.pos,
ctx.data.size,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
);
},
)
.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
@@ -165,6 +173,7 @@ where
fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
item: &TranscriptItem,
@@ -173,7 +182,7 @@ where
Rsc::State: FocusHost,
{
let (sender, markdown_src) = item_content(item);
build_text_row(rsc, selection, key, sender, &markdown_src)
build_text_row(rsc, list, selection, key, sender, &markdown_src)
}
/// A run of adjacent tool calls: collapsed to a one-line summary by
@@ -215,6 +224,7 @@ where
fn build_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
expanded: bool,
@@ -225,11 +235,12 @@ where
Rsc::State: FocusHost,
{
let text = if expanded { full } else { summary };
build_text_row(rsc, selection, key, Some("Tools"), text)
build_text_row(rsc, list, selection, key, Some("Tools"), text)
}
let content = build_content(
rsc,
list,
selection.clone(),
key,
false,
@@ -252,6 +263,7 @@ where
*expanded.borrow_mut() = !was_expanded;
let content = build_content(
rsc,
list,
selection.clone(),
key,
!was_expanded,
@@ -279,7 +291,7 @@ where
match row {
FoldedRow::Single(item) => {
let key = row_key(&item.key());
(key, build_single(rsc, selection, key, item))
(key, build_single(rsc, list, selection, key, item))
}
FoldedRow::Tools(calls) => {
let key = row_key(&calls[0].key());
+62 -1
View File
@@ -31,11 +31,16 @@
//! since that branch never goes through the approximation.
use iris::prelude::*;
use std::collections::BTreeMap;
use std::{collections::BTreeMap, time::Instant};
pub struct Selection {
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
anchor: Option<(RowKey, Vec2)>,
/// One arbiter shared by every row's drag handler -- RUST.md's I5
/// gesture conflict (a row's own `click_or_drag()` and a list-level
/// pan wanting the same touch gesture). See `drag` below, and
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
arbiter: DragArbiter,
}
impl Default for Selection {
@@ -49,6 +54,7 @@ impl Selection {
Self {
rows: BTreeMap::new(),
anchor: None,
arbiter: DragArbiter::new(),
}
}
@@ -133,6 +139,61 @@ impl Selection {
}
}
/// Whether any row currently has a non-empty selection -- what a fresh
/// press consults so `drag` knows whether an early horizontal move is
/// "start dragging the selection handle" rather than an ordinary tap.
fn has_selection(&self, ui: &mut impl UiRsc) -> bool {
self.rows
.values()
.any(|w| w.edit(ui).text.selected_text().is_some())
}
/// One row's `CursorSense::click_or_drag()` handler, for every row,
/// routes its raw pointer data through here rather than calling
/// `begin`/`extend` directly -- this is the single place that decides
/// whether the gesture pans `list` or extends a selection, so the
/// decision is made once per gesture rather than independently by
/// whichever row happens to be under the finger this frame (see
/// `DragArbiter`'s own doc for why one shared instance, not one per
/// row, is what makes that consistent as a drag crosses row
/// boundaries).
///
/// `pos_row`/`size` are row-local, as `begin`/`extend` want;
/// `pos_window` is in window space, since a pan's delta has to stay
/// meaningful even when this frame's event landed on a different row
/// than the last one.
#[allow(clippy::too_many_arguments)]
pub fn drag(
&mut self,
ui: &mut impl UiRsc,
list: WeakWidget<List>,
key: RowKey,
pos_row: Vec2,
size: Vec2,
pos_window: Vec2,
sense: CursorSense,
now: Instant,
) {
let outcome = match sense {
CursorSense::PressStart(_) => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.arbiter.update(pos_window, now)
}
CursorSense::PressEnd(_) => {
self.arbiter.release();
return;
}
_ => self.arbiter.update(pos_window, now),
};
match outcome {
DragOutcome::Undecided => {}
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
DragOutcome::SelectStart => self.begin(ui, key, pos_row, size),
DragOutcome::SelectExtend => self.extend(ui, key, pos_row, size),
}
}
/// The concatenated selected text, in row order, `None` if nothing is
/// selected -- what a copy command reads. Joins with a blank line
/// between rows, matching how the transcript itself separates them.