Clean up shared UI runtime state

This commit is contained in:
iris committed 2026-09-11 00:55:33 -04:00
1 parent f49b284b46
commit a8602c1626
43 files changed
+642 -834

No files matched your search

+6 -6
View File
@@ -32,17 +32,17 @@ fn typed_argv() -> Option<String> {
}
fn main() {
DefaultApp::<Client>::run();
DesktopApp::<Client>::run();
}
#[derive(DefaultUiState)]
#[derive(DesktopUiState)]
pub struct Client {
ui_state: DefaultUiState,
ui_state: DesktopUiState,
#[allow(dead_code)]
screen: Option<ai_app::ui::TranscriptScreen>,
}
impl DefaultAppState for Client {
impl DesktopAppState for Client {
fn window_attributes() -> WindowAttributes {
WindowAttributes::default()
.with_title("iris transcript (bench fixture)")
@@ -53,8 +53,8 @@ impl DefaultAppState for Client {
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
mut ui_state: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
+7 -7
View File
@@ -3,12 +3,12 @@ use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRo
use iris::prelude::*;
fn main() {
DefaultApp::<Client>::run();
DesktopApp::<Client>::run();
}
#[derive(DefaultUiState)]
#[derive(DesktopUiState)]
pub struct Client {
ui_state: DefaultUiState,
ui_state: DesktopUiState,
#[allow(dead_code)]
screen: ai_app::ui::TranscriptScreen,
}
@@ -157,13 +157,13 @@ fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
> A quoted line, to show the bar and the indent.
";
impl DefaultAppState for Client {
impl DesktopAppState for Client {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
mut ui_state: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
let mut screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
screen.push_row(
rsc,
&FoldedRow::Single(TranscriptItem::CommandRow {
+16 -50
View File
@@ -3,13 +3,13 @@
//! `android_logger` as the logger to forward to and nothing else.
use crate::client::log_ring::{self, LogRing};
use std::{
fs, panic,
path::{Path, PathBuf},
sync::OnceLock,
};
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
/// Installs the in-process ring in front of Android's logger.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
@@ -23,10 +23,6 @@ pub fn install(max_level: log::LevelFilter) {
)
.is_err()
{
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
@@ -40,9 +36,6 @@ pub fn ring() -> &'static LogRing {
pub fn diagnostics_line() -> String {
let where_to_read = match crate::android::devlog::authority() {
Some(authority) => format!("devlog provider: content://{authority}"),
// Not "off": Android creates a provider lazily, so this is what
// "nobody has asked for it yet" looks like, and it is a different
// thing from a build that does not have one.
None => "devlog provider: declared, not created yet".to_string(),
};
format!("{}\n{where_to_read}", ring().summary())
@@ -53,26 +46,17 @@ pub fn diagnostics_line() -> String {
/// start.
const CRASH_FILE: &str = "last-panic.txt";
/// How many of the dying run's own log lines the panic hook saves with
/// the panic, and [`set_crash_dir`] replays.
///
/// The panic's message and location say *what* broke; these say what the
/// app was doing on the way there, which is the half that is otherwise
/// unrecoverable -- the ring is memory only, so an abort takes every line
/// before the panic with it. Bounded rather than the whole ring because
/// this is written by a hook on a process that is about to die, and
/// because the replay pushes each line into the new run's ring, where an
/// unbounded paste would evict the run that is actually being watched.
/// Enough preceding log lines to explain a crash without evicting the next run.
const CRASH_CONTEXT_LINES: usize = 80;
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Copies aborting panics into the device-readable log ring.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let previous = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
@@ -81,41 +65,23 @@ fn install_panic_hook() {
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// The panic line first, then what the app was doing before
// it: one file, split again on that first newline by
// `set_crash_dir`.
let context = ring()
.try_tail_text(CRASH_CONTEXT_LINES)
.unwrap_or_else(|| {
"(the log ring was locked as this run died; no context)".to_string()
});
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, format!("{line}\n{context}"));
let _ = fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Tells the panic hook where to leave its report, and replays the report
/// a previous run left there into the ring before deleting it.
///
/// Called from **both** `MainActivity.nativeSetFilesDir` and
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
/// this process, since after a crash Dev Updater's query starts the
/// process for the provider alone and no activity ever runs. Safe to call
/// twice: the file is gone after the first, so the second finds nothing
/// and says nothing. The panic itself is replayed at `error` level and
/// says it is from the previous run, so a crash loop shows the reason it
/// is looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
/// Configures crash persistence and replays a report left by the previous run.
pub fn set_crash_dir(dir: &Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
// Delete before replaying rather than after: a replay that itself
// panicked would otherwise leave the file to be replayed again on
// every start, and a crash loop nothing can get out of is worse
// than one report lost.
let _ = std::fs::remove_file(&path);
if let Ok(previous) = fs::read_to_string(&path) {
// Delete first so a panic during replay cannot create a replay loop.
let _ = fs::remove_file(&path);
replay_crash(&previous);
}
let _ = CRASH_PATH.set(path);
+17 -10
View File
@@ -1,12 +1,19 @@
use crate::android::bench_jni::PlatformHandle;
use crate::client::transcript_fold::{TranscriptItem, fold_event};
use crate::ui::{self, TranscriptScreen};
use android_view::jni::{JavaVM, objects::GlobalRef};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::{
fs, mem,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::{Duration, Instant},
};
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
@@ -40,7 +47,7 @@ pub struct BenchClient {
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
top_bar: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
@@ -80,7 +87,7 @@ fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read.
unsafe {
let mut usage: libc::rusage = std::mem::zeroed();
let mut usage: libc::rusage = mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
return None;
}
@@ -91,7 +98,7 @@ fn process_cpu_ms() -> Option<u64> {
}
fn peak_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("VmHWM:"))
@@ -174,7 +181,7 @@ impl AndroidAppState for BenchClient {
last_top_pad: 0.0,
};
match crate::ui::fixture::build_screen(rsc) {
match ui::fixture::build_screen(rsc) {
Ok((opened, tree)) => {
client.items = opened.items;
client.stream_tail = opened.stream_tail;
@@ -392,7 +399,7 @@ impl BenchClient {
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
let (screen, tree) = ui::build_tree(rsc, ui::fixture::rows(&self.items));
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
@@ -653,7 +660,7 @@ where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
let (tx, rx) = mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
@@ -769,7 +776,7 @@ async fn run_stream_phase(
ctx.update(move |state: &mut BenchClient, rsc| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
match state.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
+3 -3
View File
@@ -4,7 +4,7 @@
use android_view::jni::JNIEnv;
use android_view::jni::objects::{JClass, JObject, JString};
use android_view::jni::sys::{jlong, jobjectArray};
use std::sync::OnceLock;
use std::{path::Path, ptr, sync::OnceLock};
/// Gated with its one reader: the tabs demo links no `client-core` and so
/// has no ring to lay out, and an ungated constant is a warning in that
@@ -43,7 +43,7 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
) {
#[cfg(feature = "transcript-screen")]
if let Some(dir) = string_arg(&mut env, &files_dir) {
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
crate::android::app_log::set_crash_dir(Path::new(&dir));
}
#[cfg(not(feature = "transcript-screen"))]
let _ = &files_dir;
@@ -138,7 +138,7 @@ fn line_fields(_since: u64) -> Vec<String> {
/// down to report that its diagnostic is unavailable would be worse than
/// the diagnostic being unavailable.
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
let null = std::ptr::null_mut();
let null = ptr::null_mut();
let Ok(class) = env.find_class("java/lang/String") else {
return null;
};
+7 -4
View File
@@ -10,7 +10,10 @@ use android_view::{
#[cfg(not(feature = "transcript-screen"))]
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use log::LevelFilter;
use std::ffi::c_void;
use std::{
ffi::c_void,
path::{Path, PathBuf},
};
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
@@ -50,7 +53,7 @@ impl HasAndroidUiState for Client {
impl AndroidAppState for Client {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
// `widgets.info` is the winit example's frame-debug readout, kept
// current from `DefaultAppState::window_event` -- android-view has
// current from `DesktopAppState::window_event` -- android-view has
// no per-frame hook to drive the equivalent from here yet, so it
// is left at its built "" text rather than wired to nothing.
let _ = tabs_ui::build(rsc, &mut ui_state);
@@ -123,8 +126,8 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
app_log::set_crash_dir(Path::new(&dir));
enrollment::set_files_dir(PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
+16 -8
View File
@@ -1,11 +1,19 @@
use crate::client::api::{ApiClient, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use crate::ui::{self, TranscriptScreen};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
thread,
};
pub struct TranscriptClient {
ui_state: AndroidUiState,
@@ -15,7 +23,7 @@ pub struct TranscriptClient {
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
session_id: Option<String>,
generation: Arc<AtomicU64>,
@@ -189,7 +197,7 @@ impl TranscriptClient {
.as_ref()
.ok()
.and_then(|values| values.last())
.and_then(crate::client::transcript_fold::raw_seq)
.and_then(raw_seq)
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
@@ -251,7 +259,7 @@ impl TranscriptClient {
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
let (screen, tree) = ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
@@ -274,14 +282,14 @@ impl TranscriptClient {
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
match &self.screen {
match self.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
std::thread::spawn(move || {
thread::spawn(move || {
if let Ok(transport) = build_transport() {
let api = ApiClient::new(transport);
let _ = api.send_message(&session_id, &text, &[]);
+15 -15
View File
@@ -1,8 +1,9 @@
use std::collections::VecDeque;
use std::collections::{HashSet, VecDeque};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
@@ -14,7 +15,7 @@ pub struct CachedTail {
pub struct TranscriptCache {
root: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
warn: Arc<dyn Fn(&str) + Send + Sync>,
}
impl TranscriptCache {
@@ -28,7 +29,7 @@ impl TranscriptCache {
) -> Self {
Self {
root: root.into(),
warn: std::sync::Arc::new(warn),
warn: Arc::new(warn),
}
}
@@ -40,7 +41,7 @@ impl TranscriptCache {
/// successful list fetch. The path out for a session deleted on
/// another device: nothing here would otherwise hear about it, and
/// unlike a draft's few bytes what it leaves behind is megabytes.
pub fn retain_only(&self, ids: &std::collections::HashSet<String>) {
pub fn retain_only(&self, ids: &HashSet<String>) {
guard_io((), self.warn.as_ref(), || {
for dir in session_dirs(&self.root)? {
if let Some(name) = dir.file_name().and_then(|n| n.to_str())
@@ -61,12 +62,12 @@ impl TranscriptCache {
/// for.
pub fn evict_to_budget(&self, keep: &str, budget: u64) {
guard_io((), self.warn.as_ref(), || {
let mut dirs: Vec<(PathBuf, std::time::SystemTime)> = session_dirs(&self.root)?
let mut dirs: Vec<(PathBuf, SystemTime)> = session_dirs(&self.root)?
.into_iter()
.map(|d| {
let modified = fs::metadata(&d)
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
.unwrap_or(UNIX_EPOCH);
(d, modified)
})
.collect();
@@ -132,7 +133,7 @@ fn dir_size(path: &Path) -> u64 {
/// closed by paging back through it, but nothing is served across one.
pub struct SessionCache {
dir: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
warn: Arc<dyn Fn(&str) + Send + Sync>,
state: Mutex<WriterState>,
}
@@ -145,7 +146,7 @@ struct WriterState {
}
impl SessionCache {
fn new(dir: PathBuf, warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>) -> Self {
fn new(dir: PathBuf, warn: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
Self {
dir,
warn,
@@ -333,7 +334,7 @@ impl SessionCache {
pub fn touch(&self) {
self.guard((), |this, _state| {
if this.dir.is_dir() {
let now = std::time::SystemTime::now();
let now = SystemTime::now();
filetime_set_modified(&this.dir, now)?;
}
Ok(())
@@ -708,7 +709,7 @@ fn guard_io<T>(
}
}
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
fn filetime_set_modified(path: &Path, _when: SystemTime) -> io::Result<()> {
use std::io::Write;
// Rewriting a marker file's contents (rather than the directory itself,
// which `std` has no portable "touch" for) bumps the directory's own
@@ -728,18 +729,17 @@ fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Resul
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn cache(temp: &Path) -> TranscriptCache {
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
let said: Arc<Mutex<Vec<String>>> = Default::default();
let said2 = said.clone();
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
said2.lock().unwrap().push(msg.to_string());
})
}
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
fn cache_with_log(temp: &Path) -> (TranscriptCache, Arc<Mutex<Vec<String>>>) {
let said: Arc<Mutex<Vec<String>>> = Default::default();
let said2 = said.clone();
(
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
+3 -2
View File
@@ -1,4 +1,5 @@
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub struct QuestionCard {
@@ -260,11 +261,11 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
/// round that loses nothing.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older
let started_earlier: HashSet<&str> = older
.iter()
.filter_map(TranscriptItem::as_tool_run)
.collect();
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
let ended_later: HashMap<String, TranscriptItem> = newer
.iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
.filter(|(id, _)| started_earlier.contains(id.as_str()))
+28 -22
View File
@@ -5,8 +5,14 @@ use crate::client::transcript_fold::{
};
use event_model::SeqEvent;
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
process,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
thread,
};
const LIST_WIDTH: f32 = 260.0;
@@ -31,12 +37,12 @@ enum AppEvent {
}
pub fn run() {
DefaultApp::<Client>::run();
DesktopApp::<Client>::run();
}
#[derive(DefaultUiState)]
#[derive(DesktopUiState)]
struct Client {
ui_state: DefaultUiState,
ui_state: DesktopUiState,
api: Arc<ApiClient<UreqTransport>>,
stream_transport: Arc<UreqTransport>,
proxy: Proxy<AppEvent>,
@@ -49,17 +55,17 @@ struct Client {
generation: Arc<AtomicU64>,
}
impl DefaultAppState for Client {
impl DesktopAppState for Client {
type Event = AppEvent;
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
mut ui_state: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
proxy: Proxy<AppEvent>,
) -> Self {
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
eprintln!("desktop-app: {e}");
std::process::exit(2);
process::exit(2);
});
let build_transport =
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
@@ -70,7 +76,7 @@ impl DefaultAppState for Client {
"desktop-app: couldn't set up TLS to {}: {e}",
server.base_url()
);
std::process::exit(1);
process::exit(1);
});
let api = Arc::new(ApiClient::new(rest_transport));
let stream_transport = Arc::new(stream_transport);
@@ -101,7 +107,7 @@ impl DefaultAppState for Client {
client
}
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>) {
fn event(&mut self, event: AppEvent, rsc: &mut DesktopRsc<Self>) {
match event {
AppEvent::Sessions(Ok(sessions)) => {
self.sessions = sessions;
@@ -141,7 +147,7 @@ impl DefaultAppState for Client {
if self.current(&session_id, generation) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, &event);
match &self.screen {
match self.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
@@ -171,7 +177,7 @@ impl Client {
&& self.generation.load(Ordering::SeqCst) == generation
}
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
fn show_message(&mut self, rsc: &mut DesktopRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.transcript_ptr)(rsc).set(widget);
}
@@ -179,13 +185,13 @@ impl Client {
fn spawn_fetch_sessions(&self) {
let api = self.api.clone();
let proxy = self.proxy.clone();
std::thread::spawn(move || {
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>) {
fn rebuild_list(&mut self, rsc: &mut DesktopRsc<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());
@@ -199,7 +205,7 @@ impl Client {
(self.list_ptr)(rsc).set(tree);
}
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
fn select_session(&mut self, rsc: &mut DesktopRsc<Self>, session_id: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.selected = Some(session_id.clone());
self.items.clear();
@@ -211,7 +217,7 @@ impl Client {
let stream_transport = self.stream_transport.clone();
let proxy = self.proxy.clone();
let live_generation = self.generation.clone();
std::thread::spawn(move || {
thread::spawn(move || {
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
@@ -257,14 +263,14 @@ impl Client {
fn send_message(&mut self, session_id: String, text: String) {
let api = self.api.clone();
let proxy = self.proxy.clone();
std::thread::spawn(move || {
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>) {
fn rebuild_transcript(&mut self, rsc: &mut DesktopRsc<Self>) {
let in_progress = self
.screen
.as_ref()
@@ -294,7 +300,7 @@ impl Client {
}
fn session_row(
rsc: &mut DefaultRsc<Client>,
rsc: &mut DesktopRsc<Client>,
session: &SessionSummary,
selected: bool,
) -> StrongWidget {
@@ -313,7 +319,7 @@ fn session_row(
.background(rect(bg))
.on(
CursorSense::click(),
move |ctx, rsc: &mut DefaultRsc<Client>| {
move |ctx, rsc: &mut DesktopRsc<Client>| {
ctx.state.select_session(rsc, id.clone());
},
)
@@ -321,7 +327,7 @@ fn session_row(
.any()
}
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
fn placeholder(rsc: &mut DesktopRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
+5 -8
View File
@@ -4,25 +4,22 @@
//! callers are in the library; the binary is only `fn main`.
use crate::client::config::EnrolledServer;
use std::{env, fs, path::PathBuf};
use super::config;
struct Args {
ca_path: Option<std::path::PathBuf>,
ca_path: Option<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);
let mut args = 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")?,
))
}
"--ca" => ca_path = Some(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}'")),
}
@@ -56,7 +53,7 @@ pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
// server at a certificate it did not carry -- and so the flag still
// means what it did before the link could carry one.
let ca_pem = match (&args.ca_path, &server.ca_pem) {
(Some(path), _) => std::fs::read(path)
(Some(path), _) => fs::read(path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
(None, Some(pem)) => pem.clone().into_bytes(),
(None, None) => {
+66 -82
View File
@@ -1,8 +1,5 @@
pub mod composer;
// The checked-in bench fixture opened as a real screen -- 1.9 MB of
// `include_str!`, so it is a feature rather than always present: a build
// meant for a phone must not carry it. `bench` turns it on; so does the
// default, which is what makes `cargo test` here run the harness tests.
// Keep the 1.9 MB fixture out of ordinary APKs.
#[cfg(feature = "fixture")]
pub mod fixture;
pub mod markdown;
@@ -11,9 +8,9 @@ pub(crate) mod tap;
pub mod theme;
pub mod tool;
use crate::client::transcript_fold::TranscriptRow as FoldedRow;
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs};
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
use std::{mem, rc::Rc};
use theme::Theme;
pub struct TranscriptScreen {
@@ -25,9 +22,9 @@ pub struct TranscriptScreen {
/// `.jump_to_end()` directly.
pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer,
rebuilds: std::cell::Cell<usize>,
tail: RefCell<Option<(RowKey, row::TailRow)>>,
session_working: std::cell::Cell<bool>,
rebuilds: usize,
tail: Option<(RowKey, row::TailRow)>,
session_working: bool,
theme: Rc<Theme>,
}
@@ -36,7 +33,7 @@ impl TranscriptScreen {
/// what a caller's SSE loop or a sent message calls as new events
/// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
/// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
pub fn push_row<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, row: &FoldedRow)
where
Rsc::State: FocusHost + OpenUrl,
{
@@ -45,27 +42,27 @@ impl TranscriptScreen {
// grows through `RowBlocks::apply_delta`, which appends to what is
// already drawn -- so the cap only ever catches a row that arrived
// long, which is the one nobody is watching arrive.
let (key, widget, tail) = row::build_row(
let row::BuiltRow { key, widget, tail } = row::build_row(
rsc,
self.list,
row,
self.session_working.get(),
self.session_working,
true,
self.theme.clone(),
);
(self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t));
self.tail = tail.map(|t| (key, t));
}
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
pub fn set_session_working<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, working: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.session_working.replace(working) == working {
if self.session_working == working {
return;
}
let mut tail = self.tail.borrow_mut();
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
self.session_working = working;
if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() {
let calls = tools.calls();
tools.apply_calls(rsc, &calls, working);
}
@@ -73,7 +70,7 @@ impl TranscriptScreen {
#[cfg(test)]
fn tail_card_count(&self) -> usize {
match self.tail.borrow().as_ref() {
match self.tail.as_ref() {
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
_ => 0,
}
@@ -84,12 +81,11 @@ impl TranscriptScreen {
/// displayless machine, and the tests below). Answers whether there
/// was such a row to act on, so a caller that expected one can say so
/// rather than silently producing the collapsed picture.
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
pub fn expand_tail_tools<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, expanded: bool) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let tail = self.tail.borrow();
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
let Some((_, row::TailRow::Tools(tools))) = self.tail.as_ref() else {
return false;
};
tools.set_group_expanded(rsc, expanded);
@@ -99,12 +95,16 @@ impl TranscriptScreen {
/// The `ReplaceLast` fast path: update the tail row in place if this
/// really is a change to the same row, and say whether that worked.
/// `false` for anything the caller must rebuild instead.
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
fn apply_tail_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
key: RowKey,
row: &FoldedRow,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let mut tail = self.tail.borrow_mut();
let Some((tail_key, kept)) = tail.as_mut() else {
let Some((tail_key, kept)) = self.tail.as_mut() else {
return false;
};
if *tail_key != key {
@@ -116,19 +116,16 @@ impl TranscriptScreen {
// A tool call is drawn as a card, never as markdown, so a
// row that kept blocks and now holds one is a different
// row -- rebuild it.
if matches!(
item,
crate::client::transcript_fold::TranscriptItem::ToolRun { .. }
) {
if matches!(item, TranscriptItem::ToolRun { .. }) {
return false;
}
blocks.apply_delta(rsc, sender, &markdown_src)
}
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get())
tools.apply_calls(rsc, calls, self.session_working)
}
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working)
}
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
}
@@ -143,15 +140,13 @@ impl TranscriptScreen {
/// often the fallback actually fires rather than assuming it never
/// does.
pub fn apply<Rsc: HasEvents>(
&self,
&mut self,
rsc: &mut Rsc,
old: &[crate::client::transcript_fold::TranscriptItem],
new: &[crate::client::transcript_fold::TranscriptItem],
old: &[TranscriptItem],
new: &[TranscriptItem],
) where
Rsc::State: FocusHost + OpenUrl,
{
use crate::client::transcript_fold::group_tool_runs;
let old_rows = group_tool_runs(old);
let new_rows = group_tool_runs(new);
@@ -172,25 +167,29 @@ impl TranscriptScreen {
return;
}
let (new_key, widget, kept) = row::build_row(
let row::BuiltRow {
key: new_key,
widget,
tail: kept,
} = row::build_row(
rsc,
self.list,
&new_rows[common],
self.session_working.get(),
self.session_working,
false,
self.theme.clone(),
);
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
self.tail = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
}
RowDiff::Rebuild => {
self.rebuilds.set(self.rebuilds.get() + 1);
self.rebuilds += 1;
(self.list)(rsc).clear();
*self.tail.borrow_mut() = None;
self.tail = None;
for row in &new_rows {
self.push_row(rsc, row);
}
@@ -198,8 +197,8 @@ impl TranscriptScreen {
}
}
pub fn take_rebuilds(&self) -> usize {
self.rebuilds.replace(0)
pub fn take_rebuilds(&mut self) -> usize {
mem::take(&mut self.rebuilds)
}
/// The semantic paint IDs used by this screen. A caller can replace
@@ -268,7 +267,11 @@ where
// be a reply already streaming when this screen opened, and a
// capped row cannot take a delta (`RowBlocks::capped`).
let cap = i + 1 < rows.len();
let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone());
let row::BuiltRow {
key,
widget,
tail: kept,
} = row::build_row(rsc, list, row, false, cap, theme.clone());
list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t));
}
@@ -303,11 +306,11 @@ where
(
TranscriptScreen {
tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false),
tail,
session_working: false,
list,
composer,
rebuilds: std::cell::Cell::new(0),
rebuilds: 0,
theme,
},
tree,
@@ -348,7 +351,6 @@ fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
#[cfg(test)]
mod diff_tests {
use super::*;
use crate::client::transcript_fold::TranscriptItem;
fn user(seq: u64, text: &str) -> FoldedRow {
FoldedRow::Single(TranscriptItem::UserMsg {
@@ -433,7 +435,8 @@ mod diff_tests {
#[cfg(test)]
mod apply_tests {
use super::*;
use crate::client::transcript_fold::TranscriptItem;
use crate::client::text_cap::MESSAGE_LINES;
use std::iter;
struct TestFocus {
focus: Option<WeakWidget<TextEdit>>,
@@ -523,10 +526,7 @@ mod apply_tests {
1,
&reply(paragraphs, "and the last one is still going."),
)];
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&old_items),
);
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
@@ -535,7 +535,7 @@ mod apply_tests {
screen.apply(&mut rsc, &old_items, &new_items);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
let (draws, _, _, shapes) = render.take_counters();
let RenderCounters { draws, shapes, .. } = render.take_counters();
(draws, shapes)
}
@@ -585,8 +585,7 @@ mod apply_tests {
rsc: &mut TestRsc,
items: &[TranscriptItem],
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
let (screen, tree) =
build_tree(rsc, crate::client::transcript_fold::group_tool_runs(items));
let (mut screen, tree) = build_tree(rsc, group_tool_runs(items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, rsc);
@@ -605,10 +604,7 @@ mod apply_tests {
events: EventManager::default(),
};
let items = run_of(3, output, true);
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&items),
);
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
@@ -619,13 +615,12 @@ mod apply_tests {
"the fixture's only row must be the tool run"
);
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
render.take_counters().shapes
}
#[test]
fn collapsed_cards_shape_only_their_summary_lines() {
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
let long: String = iter::repeat_n("a line of tool output\n", 4_000).collect();
assert!(long.len() > 80_000, "the long case must actually be long");
let short_shapes = shapes_to_open("ok\n");
@@ -659,22 +654,18 @@ mod apply_tests {
settled: true,
},
];
let (_screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&items),
);
let (_screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
render.take_counters().shapes
}
#[test]
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
let capped = shapes_for_message(&paragraphs(crate::client::text_cap::MESSAGE_LINES * 4));
let bigger = shapes_for_message(&paragraphs(crate::client::text_cap::MESSAGE_LINES * 40));
let capped = shapes_for_message(&paragraphs(MESSAGE_LINES * 4));
let bigger = shapes_for_message(&paragraphs(MESSAGE_LINES * 40));
assert!(
capped > 0,
"the screen shaped nothing, so this compares zeroes"
@@ -695,7 +686,7 @@ mod apply_tests {
let mut after = before.clone();
after[0] = call("t0", "the result", true);
let (screen, tree, mut render) = open_run(&mut rsc, &before);
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
@@ -703,8 +694,7 @@ mod apply_tests {
0,
"a result arriving must not rebuild the whole screen"
);
let (draws, _, _, _) = render.take_counters();
draws
render.take_counters().draws
}
#[test]
@@ -732,10 +722,7 @@ mod apply_tests {
let mut after = before.clone();
after[1] = call("t1", "done", true);
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&before),
);
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&before));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
@@ -767,7 +754,7 @@ mod apply_tests {
let mut after = before.clone();
after.push(call("t2", "", false));
let (screen, tree, mut render) = open_run(&mut rsc, &before);
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
assert_eq!(screen.tail_card_count(), 2);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
@@ -787,10 +774,7 @@ mod apply_tests {
};
let before = vec![user(1, "stable"), call("t0", "", false)];
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
let (screen, _tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&before),
);
let (mut screen, _tree) = build_tree(&mut rsc, group_tool_runs(&before));
screen.apply(&mut rsc, &before, &after);
assert_eq!(
screen.take_rebuilds(),
+89 -113
View File
@@ -1,32 +1,31 @@
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use crate::client::transcript_fold::{
ItemKey, QuestionCard, TranscriptItem, TranscriptRow as FoldedRow,
};
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use crate::ui::tool::ToolRow;
use crate::ui::tool::{ToolRow, build_tool_row};
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
use std::{
cell::RefCell,
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
rc::Rc,
slice,
};
const BLOCK_GAP_DP: f32 = 8.0;
pub const BASE_SIZE: f32 = 16.0;
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
/// one -- collisions are not a correctness risk worth guarding against here
/// (a `DefaultHasher` collision across the run ids one session produces is
/// astronomically unlikely, and the consequence of one would only be two
/// tool-call rows sharing a list slot, not data loss), and the high bit is
/// forced on so a hashed key can never collide with a real sequence number
/// (this build never produces 2^63 events).
pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey {
use crate::client::transcript_fold::ItemKey;
use std::hash::{Hash, Hasher};
/// Maps string run IDs above the sequence-number range used by transcripts.
pub fn row_key(key: &ItemKey) -> RowKey {
match key {
ItemKey::Seq(seq) => *seq,
ItemKey::RunId(id) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut h = DefaultHasher::new();
id.hash(&mut h);
h.finish() | (1 << 63)
}
@@ -94,19 +93,10 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
pub struct RowBlocks {
blocks: Vec<Block>,
fields: Vec<WeakWidget<Text>>,
links: Vec<Rc<RefCell<Vec<Link>>>>,
links: Vec<LinkTargets>,
column: WeakWidget<Span>,
sender: Option<String>,
/// Whether this row draws less than the whole message
/// ([`cap_message`]). A delta cannot be appended to a capped row --
/// the new text would go on *below* the "Show all" that says it is
/// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller
/// rebuilds instead.
///
/// Never `true` for the row a reply is actually streaming into: the
/// live tail is built uncapped ([`build_row`]'s `cap`), which is what
/// keeps the refusal from costing anything in practice. This field is
/// the belt to that braces.
/// A capped row must be rebuilt before accepting a delta.
capped: bool,
theme: Rc<Theme>,
}
@@ -126,17 +116,7 @@ fn display_blocks(markdown_src: &str) -> Vec<Block> {
}
}
/// `blocks` cut to what a row draws, with the line count of the **whole**
/// message; `None` when all of it fits.
///
/// The cut prefers a **block boundary**, because a message is markdown and
/// a whole paragraph is a smaller version of a message in a way that half
/// a paragraph is not. Where one block is over the bound by itself -- the
/// reply that is one enormous fence -- that block is truncated instead of
/// being dropped or drawn whole: dropping it would leave a row saying
/// nothing, and a truncated fence still renders as a fence, since the
/// renderer already knows the block's kind and pulldown-cmark closes an
/// unterminated one at the end of its input.
/// Caps at a block boundary when possible, or within the first oversized block.
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
if !cap {
@@ -167,30 +147,50 @@ fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
(kept, None)
}
/// A message's own text, kept so that asking for the whole of a capped row
/// can rebuild it. `Rc` rather than a copy per closure: the source of a
/// long message is the largest string in the row, and the tap handler
/// would otherwise hold a second one for the lifetime of the row.
/// Shared with the "Show all" callback to avoid copying a long message.
struct RowSource {
sender: Option<String>,
markdown: String,
}
#[derive(Clone)]
struct LinkTargets(Rc<RefCell<Vec<Link>>>);
impl LinkTargets {
fn new(links: Vec<Link>) -> Self {
Self(Rc::new(RefCell::new(links)))
}
fn replace(&self, links: Vec<Link>) {
*self.0.borrow_mut() = links;
}
fn url_at(&self, byte: usize) -> Option<String> {
self.0
.borrow()
.iter()
.find(|link| link.range.contains(&byte))
.map(|link| link.url.clone())
}
}
struct BuiltBlock {
field: WeakWidget<Text>,
widget: StrongWidget,
links: LinkTargets,
}
const FRAME_PAD_DP: f32 = 10.0;
const QUOTE_BAR_DP: f32 = 3.0;
const FRAME_RADIUS_DP: f32 = 8.0;
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc,
block: &Block,
theme: &Theme,
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
fn build_block<Rsc: HasEvents>(rsc: &mut Rsc, block: &Block, theme: &Theme) -> BuiltBlock
where
Rsc::State: FocusHost + OpenUrl,
{
let frame = frame_of(block.kind, theme);
let rendered = render_block(block, BASE_SIZE, theme);
let links = Rc::new(RefCell::new(rendered.links));
let links = LinkTargets::new(rendered.links);
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
@@ -218,17 +218,10 @@ where
selection.drag(id, rsc, input)
})
.unwrap_or(SelectionInput::Tapped);
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
// Panning or selecting across a link must not open it.
if outcome == SelectionInput::Tapped {
let byte = field.selection(rsc).byte_at(pos, size);
let url = tap_links
.borrow()
.iter()
.find(|l| l.range.contains(&byte))
.map(|l| l.url.clone());
let url = tap_links.url_at(byte);
if let Some(url) = url {
log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
@@ -257,7 +250,11 @@ where
.add_strong(rsc)
.any(),
};
(field, framed, links)
BuiltBlock {
field,
widget: framed,
links,
}
}
#[allow(clippy::too_many_arguments)]
@@ -305,10 +302,10 @@ where
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for block in &blocks {
let (field, framed, block_links) = build_block(rsc, block, &theme);
fields.push(field);
links.push(block_links);
column.push(framed);
let built = build_block(rsc, block, &theme);
fields.push(built.field);
links.push(built.links);
column.push(built.widget);
}
if let Some(lines) = hidden {
column.push(show_all(
@@ -323,14 +320,7 @@ where
}
let column = column.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
// what performs the *one* real strong registration each child gets.
// Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()`
// copy into that composition, tried to strong-register the same id
// twice and panicked with "was already added"
// (`core/src/widget/like.rs:12`) -- found running this crate's own
// `run-headless.sh` example, the first real render of a row.
// The parent composition performs the header's single strong registration.
let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.clone())
.size(13.0)
@@ -359,9 +349,7 @@ where
)
}
/// The `RowBlocks` the rebuild produces is **discarded**, because a capped
/// row is never the row a reply is streaming into (`build_row`'s `cap`) --
/// so nothing is holding one for it, and there is nothing to keep in step.
/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded.
#[allow(clippy::too_many_arguments)]
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
@@ -395,12 +383,7 @@ where
}
impl RowBlocks {
/// Bring this row up to date with `markdown_src` **without** re-laying
/// out the blocks that did not change, and say whether that was
/// possible. `false` means the caller must rebuild the row the
/// ordinary way: an earlier block was rewritten (markdown allows it --
/// a trailing `---` turns the paragraph above into a heading), the
/// sender changed, or the message got shorter.
/// Updates only the changed tail blocks, or returns `false` when a rebuild is required.
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
@@ -413,20 +396,12 @@ impl RowBlocks {
if self.sender.as_deref() != sender {
return false;
}
// A capped row draws less than the message it was built from, so
// appending to it would put the new text *below* the "Show all"
// saying the rest is hidden. The caller rebuilds instead, and
// rebuilds uncapped (`TranscriptScreen::apply`), so this refusal
// costs one rebuild per message rather than one per delta.
if self.capped {
return false;
}
let new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks);
// Everything already drawn must either be kept whole (`common ==
// len`, a pure append) or be kept except for the last block, which
// is the one a delta lands in. Anything else means an already
// laid-out block is no longer what it was.
// A delta may append or rewrite only the current final block.
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
return false;
}
@@ -449,14 +424,14 @@ impl RowBlocks {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE, &self.theme);
field(rsc).set_with_spans(rendered.text, rendered.spans);
*links.borrow_mut() = rendered.links;
links.replace(rendered.links);
}
_ => {
let (field, framed, links) = build_block(rsc, block, &self.theme);
self.fields.push(field);
self.links.push(links);
let built = build_block(rsc, block, &self.theme);
self.fields.push(built.field);
self.links.push(built.links);
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(framed);
column.push(built.widget);
}
}
}
@@ -481,20 +456,19 @@ where
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
}
/// Two mechanisms would have been two answers to the same question ("what
/// can this row do cheaply?"), so the caller holds one of these for its
/// tail row and asks it, rather than holding a `RowBlocks` and a
/// `ToolRow` and choosing between them at each call site.
/// Incremental state retained for whichever kind of row is currently last.
pub enum TailRow {
Blocks(RowBlocks),
Tools(ToolRow),
}
/// `cap` draws a long message as [`cap_message`]'s worth of it behind a
/// "Show all"; the caller passes `false` for the **live tail**, the row a
/// reply is streaming into, because a row that grows while it is capped
/// would appear to stop growing (`RowBlocks::capped`). Every other row is
/// capped.
pub struct BuiltRow {
pub key: RowKey,
pub widget: StrongWidget,
pub tail: Option<TailRow>,
}
/// `cap` limits historical rows; a live tail must remain uncapped.
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
@@ -502,33 +476,35 @@ pub fn build_row<Rsc: HasEvents>(
working: bool,
cap: bool,
theme: Rc<Theme>,
) -> (RowKey, StrongWidget, Option<TailRow>)
) -> BuiltRow
where
Rsc::State: FocusHost + OpenUrl,
{
// A lone tool call is a card too, not a message with markdown in it:
// `group_tool_runs` leaves one call as a `Single` because "Called 1
// tool" hides a card to say the same thing in more words, and the
// *card* is what both cases draw (`ToolRows.kt`).
// A single tool call still draws as a card, without a redundant group wrapper.
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
Some(std::slice::from_ref(item))
}
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(slice::from_ref(item)),
FoldedRow::Tools(calls) => Some(calls.as_slice()),
FoldedRow::Single(_) => None,
};
if let Some(calls) = calls {
let key = row_key(&calls[0].key());
let (widget, tools) =
crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
return (key, widget, Some(TailRow::Tools(tools)));
let (widget, tools) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
return BuiltRow {
key,
widget,
tail: Some(TailRow::Tools(tools)),
};
}
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
(key, widget, Some(TailRow::Blocks(blocks)))
BuiltRow {
key,
widget,
tail: Some(TailRow::Blocks(blocks)),
}
}
#[cfg(test)]
+6 -2
View File
@@ -5,7 +5,11 @@ use crate::ui::markdown::highlight_into;
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
use std::{
cell::{Cell, RefCell},
collections::{HashMap, HashSet},
rc::Rc,
};
const NAME_SIZE: f32 = 14.0;
const BODY_SIZE: f32 = 12.0;
@@ -591,7 +595,7 @@ impl ToolRow {
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec();
let ids: std::collections::HashSet<String> = calls
let ids: HashSet<String> = calls
.iter()
.filter_map(|c| match c {
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
+2 -2
View File
@@ -49,7 +49,7 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
let mut screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
@@ -119,7 +119,7 @@ fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
let mut screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
+1 -1
View File
@@ -87,7 +87,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let (mut h, mut screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\