From e5d537e4e1c8eef568dd7ff51ec00bc7899693bb Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:55:44 -0700 Subject: [PATCH] fix(glance): the panel's cards run their taps and chat; threads keep Mail's account The desktop glance panel drew L0 cards with their chips and buttons but ran none of their taps: only the card window did (glance_sheet.rs dispatch_taps), which only a card's toast opens, while every new card opens the panel. A card's buttons and its in-card chat (sys.chat) did nothing there. - glance_card.rs: `LiveCards`, kept by both surfaces. A tile's L0 taps, taken from its own isolate only, run through its card's L0Session, made for the app that published the card: the declared transition, the host's sys.chat write (glance_chat.rs, octosense-l0-chat) and a re-lowering when a tap or the agent's reply moved the card. take_taps now takes one isolate's taps and leaves the others' (two surfaces dispatch), and a swept tile drops its queued taps (a later isolate may get the same heap key). - glance_panel.rs: tiles draw their card as its session has it and dispatch after each event. glance_sheet.rs uses the same path; its redraw now repaints its own area, so a reply that comes later shows. - Chat threads: glance_chat.rs keeps a thread in the account folder of the account the app's agent acts for. contained_account_in read whether the app keeps accounts from its recorded storage block, recorded only when the app opens or its agent is prepared; asked earlier (the demo's seed at startup), it answered `device` for a signed-in Mail. It now reads the manifest's block when none was recorded yet. - octosense-l0-chat: a reply is kept in the thread its message went to, even when the account switched while the agent answered. - glance_chat.rs: HostResponder's doc no longer says Mail has no agent; the canned reply under OCTOSENSE_GLANCE_DEMO=mail stays by design. - desktop README (en, zh-CN): the demo cards work the same in the panel. Co-Authored-By: Claude Opus 5.5 --- crates/l0-chat/README.md | 2 +- crates/l0-chat/src/lib.rs | 87 +++-- crates/l0-chat/src/tests.rs | 72 +++++ crates/shell/src/app_storage/lifecycle.rs | 19 +- .../shell/src/app_storage/lifecycle_tests.rs | 22 ++ crates/shell/src/app_storage/mod.rs | 6 + crates/shell/src/glance_card.rs | 304 ++++++++++++++++-- crates/shell/src/glance_chat.rs | 59 +++- crates/shell/src/glance_panel.rs | 18 +- crates/shell/src/glance_sheet.rs | 82 ++--- desktop/README.md | 2 +- desktop/README.zh-CN.md | 2 +- 12 files changed, 564 insertions(+), 111 deletions(-) diff --git a/crates/l0-chat/README.md b/crates/l0-chat/README.md index 40e5d1e1..06b587ba 100644 --- a/crates/l0-chat/README.md +++ b/crates/l0-chat/README.md @@ -20,7 +20,7 @@ Octoscript's L0 checker admits the card. The rest is the host's, and is here | **The transcript is the host's** | `seed` puts the host's own transcript under every `sys.chat` source's name, replacing whatever the publisher put in its `data`: no forged `model` entry, no other app's thread. | | **Roles** | Entries are `{id, role, text, at}`, `role` one of `user`, `model`, `host`. A card's one write is `append`, recorded as a `user` entry only when its payload is what the person typed (`ValueOrigin::UserInput`). Only the host's `ChatStore::append_reply` writes `model` (the agent's answer, drawn AI-written) or `host` (a notice). A payload that looks like `{"role":"model",…}` is just the text of a `user` entry. | | **Limits** | `TEXT_MAX_BYTES` 4 KiB per message after trimming, control characters dropped; one message per thread every `MIN_INTERVAL_MS` (2 s) and none while the agent is still answering; the last `RETENTION` (200) entries kept; a reply cut at `REPLY_MAX_BYTES` (16 KiB); app and thread ids at most `ID_MAX` (64), threads `[A-Za-z0-9_-]`. | -| **Storage** | `ChatStore::with_folder` keeps one JSON file per (app, thread) in the folder the host names; `ChatStore::in_memory` for tests. The shell names the app's account folder (ADR 0004 §11): `apps//accounts//chat/.json`, owner-only, for the account the app's agent acts for (`device` without one), so switching accounts switches transcripts. AppCard keeps its chats in `apps/appcard/accounts/device/chat/`. | +| **Storage** | `ChatStore::with_folder` keeps one JSON file per (app, thread) in the folder the host names; `ChatStore::in_memory` for tests. The shell names the app's account folder (ADR 0004 §11): `apps//accounts//chat/.json`, owner-only, for the account the app's agent acts for (`device` without one), so switching accounts switches transcripts; a reply is kept in the thread its message went to, even if the account switched while the agent answered. AppCard keeps its chats in `apps/appcard/accounts/device/chat/`. | | **Stale** | Every change bumps `ChatStore::generation` and calls the host's change hook (`set_on_change`), so a surface re-seeds and re-lowers the card when the reply arrives. | ## Who answers: `Responder` diff --git a/crates/l0-chat/src/lib.rs b/crates/l0-chat/src/lib.rs index 5620f7c8..11003f66 100644 --- a/crates/l0-chat/src/lib.rs +++ b/crates/l0-chat/src/lib.rs @@ -35,7 +35,9 @@ //! - **Storage.** Threads are kept per app and thread, in the folder the //! host's [`ChatStore::with_folder`] names for the app (the shell: the //! app's account folder under ADR 0004's layout, `apps//accounts/ -//! /chat/.json`), or in memory. +//! /chat/.json`), or in memory. A reply is kept in the +//! thread its message went to, even when the host names another folder +//! by then (another account signed in while the agent answered). //! - **Stale.** Every change bumps [`ChatStore::generation`] and calls the //! host's change hook, so a surface re-seeds and re-lowers the card: the //! write marks the source stale (§5.9) and the reply arrives later. @@ -255,8 +257,8 @@ impl ChatStore { .unwrap_or_default() } - fn save(&self, app: &str, thread: &str, t: &Thread) { - let Some(path) = self.file(app, thread) else { + fn save(&self, app: &str, thread: &str, file: &Option, t: &Thread) { + let Some(path) = file else { return; }; let disk = json!({"schema": SCHEMA, "entries": t.entries, "next": t.next}); @@ -267,23 +269,40 @@ impl ChatStore { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; } - std::fs::rename(&tmp, &path) + std::fs::rename(&tmp, path) }); if let Err(e) = written { eprintln!("l0 chat: could not keep {app}/{thread}: {e}"); } } - fn with_thread(&self, app: &str, thread: &str, f: impl FnOnce(&mut Thread) -> R) -> R { + /// `f` on the thread in the folder the host names for the app now. + /// Keyed by the file too: when the host's folder for the app changes + /// (another account is active), the thread is that folder's. + fn with_thread( + &self, + app: &str, + thread: &str, + f: impl FnOnce(&mut Thread, &Option) -> R, + ) -> R { + self.with_thread_at(app, thread, self.file(app, thread), f) + } + + /// `f` on the thread kept in `file` (`None`: in memory), and that file. + fn with_thread_at( + &self, + app: &str, + thread: &str, + file: Option, + f: impl FnOnce(&mut Thread, &Option) -> R, + ) -> R { let mut threads = self.threads.lock().unwrap_or_else(|e| e.into_inner()); - // Keyed by the file too: when the host's folder for the app changes - // (another account is active), the thread is that folder's. - let key = (app.to_string(), thread.to_string(), self.file(app, thread)); + let key = (app.to_string(), thread.to_string(), file); if !threads.contains_key(&key) { let loaded = self.load(&key.2); threads.insert(key.clone(), loaded); } - f(threads.get_mut(&key).expect("just inserted")) + f(threads.get_mut(&key).expect("just inserted"), &key.2) } /// The thread's entries, oldest first. @@ -291,7 +310,7 @@ impl ChatStore { if !valid_app(app) || !valid_thread(thread) { return Vec::new(); } - self.with_thread(app, thread, |t| t.entries.clone()) + self.with_thread(app, thread, |t, _| t.entries.clone()) } /// `sys.chat`'s answer for one thread: `{status, count, entries}`; @@ -300,7 +319,7 @@ impl ChatStore { if !valid_app(app) || !valid_thread(thread) { return unavailable(); } - self.with_thread(app, thread, |t| { + self.with_thread(app, thread, |t, _| { json!({ "status": if t.answering { "answering" } else { "ready" }, "count": t.entries.len(), @@ -317,6 +336,18 @@ impl ChatStore { thread: &str, text: &str, now: u64, + ) -> Result { + self.append_user_at(app, thread, self.file(app, thread), text, now) + } + + /// [`Self::append_user`] to the thread kept in `file`. + fn append_user_at( + &self, + app: &str, + thread: &str, + file: Option, + text: &str, + now: u64, ) -> Result { if !valid_app(app) || !valid_thread(thread) { return Err(format!("not a conversation: {app}/{thread}")); @@ -331,7 +362,7 @@ impl ChatStore { text.len() )); } - let entry = self.with_thread(app, thread, |t| { + let entry = self.with_thread_at(app, thread, file, |t, file| { if t.answering { return Err("the agent is still answering the last message".to_string()); } @@ -346,7 +377,7 @@ impl ChatStore { let entry = push(t, Role::User, text, now); t.last_user_ms = Some(now); t.answering = true; - self.save(app, thread, t); + self.save(app, thread, file, t); Ok(entry) })?; self.changed(); @@ -356,6 +387,18 @@ impl ChatStore { /// The host appends the agent's reply (`model`) or a notice (`host`), /// and the thread takes messages again. pub fn append_reply(&self, app: &str, thread: &str, reply: Reply, now: u64) -> Option { + self.append_reply_at(app, thread, self.file(app, thread), reply, now) + } + + /// [`Self::append_reply`] to the thread kept in `file`. + fn append_reply_at( + &self, + app: &str, + thread: &str, + file: Option, + reply: Reply, + now: u64, + ) -> Option { if !valid_app(app) || !valid_thread(thread) { return None; } @@ -367,10 +410,10 @@ impl ChatStore { Reply::Notice(text) => (Role::Host, text), }; let text = cut(text.trim(), REPLY_MAX_BYTES); - let entry = self.with_thread(app, thread, |t| { + let entry = self.with_thread_at(app, thread, file, |t, file| { let entry = push(t, role, text, now); t.answering = false; - self.save(app, thread, t); + self.save(app, thread, file, t); entry }); self.changed(); @@ -389,14 +432,14 @@ impl ChatStore { if !valid_app(app) || !valid_thread(thread) { return false; } - let seeded = self.with_thread(app, thread, |t| { + let seeded = self.with_thread(app, thread, |t, file| { if !t.entries.is_empty() { return false; } for (role, text) in entries { push(t, *role, text.to_string(), now); } - self.save(app, thread, t); + self.save(app, thread, file, t); true }); if seeded { @@ -618,8 +661,12 @@ pub fn perform( )); } let thread = thread_of(&source, state, data).ok_or("sys.chat: no valid thread")?; - let entry = chat.append_user(&app, &thread, &write.value, now)?; - let history = chat.entries(&app, &thread); + // The reply goes where the message went: the thread in the folder the + // host names now, even when it names another (another account signed + // in) before the agent answers. + let file = chat.file(&app, &thread); + let entry = chat.append_user_at(&app, &thread, file.clone(), &write.value, now)?; + let history = chat.with_thread_at(&app, &thread, file.clone(), |t, _| t.entries.clone()); let store = chat.clone(); let (a, t) = (app.clone(), thread.clone()); responder.respond( @@ -630,7 +677,7 @@ pub fn perform( history, }, Box::new(move |reply| { - store.append_reply(&a, &t, reply, now_ms()); + store.append_reply_at(&a, &t, file, reply, now_ms()); }), ); Ok(entry) diff --git a/crates/l0-chat/src/tests.rs b/crates/l0-chat/src/tests.rs index 51203374..43a60d00 100644 --- a/crates/l0-chat/src/tests.rs +++ b/crates/l0-chat/src/tests.rs @@ -498,3 +498,75 @@ fn threads_are_kept_in_the_apps_folder() { .is_empty() ); } + +/// A thread is the folder's the host names for the app now: another +/// account signed in, another thread. A reply goes to the thread its +/// message went to, even when the account switched while the agent +/// answered: never into the new account's thread, and the first thread +/// takes messages again. +#[test] +fn a_reply_stays_with_the_account_its_message_went_to() { + struct Later(Mutex>); + impl Responder for Later { + fn respond(&self, _: Request, done: Done) { + *self.0.lock().unwrap() = Some(done); + } + } + let root = std::env::temp_dir().join(format!("l0-chat-accounts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let account = Arc::new(Mutex::new("a")); + let folder = { + let (root, account) = (root.clone(), account.clone()); + move |app: &str| { + let dir = root.join(app).join(*account.lock().unwrap()).join("chat"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) + } + }; + let chat = Arc::new(ChatStore::with_folder(Box::new(folder))); + let later = Later(Mutex::new(None)); + let (write, origin, data) = send(&chat, "os.news", CARD, "asked as a"); + perform( + &chat, + &later, + "os.news", + CARD, + &InstanceStore::default(), + &data, + &write, + origin, + 0, + ) + .unwrap(); + // Another account signs in while the agent answers. + *account.lock().unwrap() = "b"; + assert!(chat.entries("os.news", "main").is_empty(), "b's own thread"); + let done = later.0.lock().unwrap().take().unwrap(); + done(Reply::Model("for a".into())); + assert!( + chat.entries("os.news", "main").is_empty(), + "a's reply is not b's" + ); + assert!(!root.join("os.news/b/chat/main.json").exists()); + *account.lock().unwrap() = "a"; + assert_eq!( + roles(&chat, "os.news", "main"), + [ + (Role::User, "asked as a".to_string()), + (Role::Model, "for a".to_string()) + ] + ); + assert_eq!( + chat.answer("os.news", "main")["status"], + "ready", + "a's thread takes messages again" + ); + let kept = ChatStore::with_folder(Box::new(move |app: &str| { + Some(root.join(app).join("a").join("chat")) + })); + assert_eq!( + kept.entries("os.news", "main").len(), + 2, + "kept on disk as a's" + ); +} diff --git a/crates/shell/src/app_storage/lifecycle.rs b/crates/shell/src/app_storage/lifecycle.rs index b587b2c0..d7c8c06e 100644 --- a/crates/shell/src/app_storage/lifecycle.rs +++ b/crates/shell/src/app_storage/lifecycle.rs @@ -171,13 +171,20 @@ pub fn account_changed(storage: &Arc, service_app: &str, previous: Opti /// opened): record its manifest's storage block, so its agent acts for the /// right account and its suspension is keyed right; then `prepare`. pub fn prepare_agent_with(storage: &Storage, root: &Path, app_id: &str, prepare: impl FnOnce(&Storage) -> R) -> R { + record_manifest_spec(storage, root, app_id); + prepare(storage) +} + +/// Record the storage block of the script app `app_id`'s manifest, when +/// one is on disk ([`script_manifest`]); a block the host refuses is +/// logged, and the default holds. +fn record_manifest_spec(storage: &Storage, root: &Path, app_id: &str) { if let Some(manifest) = script_manifest(root, app_id) { match StorageSpec::from_manifest(&manifest, AppKind::Script) { Ok(spec) => storage.set_spec(app_id, spec), Err(e) => makepad_widgets::log!("app storage: {app_id}: {e}"), } } - prepare(storage) } /// The account a contained app's agent acts for (`contained::set_account_of`). @@ -192,7 +199,17 @@ pub fn contained_account(app: &str) -> Option { /// (Mail), its active account (the one the person signed in to last, from /// Mail's host service), or none yet. One agent is live per app at a time, /// bound to that account (ADR 0004 §11). +/// +/// Whether the app keeps accounts is its manifest's storage block. Nothing +/// may have recorded it yet in this run: the account is also asked before +/// the app was opened or its agent prepared (a glance card's chat thread at +/// startup, glance_chat.rs), so the block is read from the manifest then, +/// as [`prepare_agent_with`] does. The default, no accounts, would answer +/// the device for a signed-in Mail. pub fn contained_account_in(storage: &Storage, app: &str) -> Option { + if !storage.has_spec(app) { + record_manifest_spec(storage, storage.layout().apps_root(), app); + } if !storage.spec(app).accounts { return Some(crate::ai_host::contained::ACCOUNT.to_owned()); } diff --git a/crates/shell/src/app_storage/lifecycle_tests.rs b/crates/shell/src/app_storage/lifecycle_tests.rs index cc3551a6..46585f1c 100644 --- a/crates/shell/src/app_storage/lifecycle_tests.rs +++ b/crates/shell/src/app_storage/lifecycle_tests.rs @@ -265,6 +265,28 @@ fn should_bind_a_contained_agent_to_the_active_account_when_the_app_keeps_accoun assert_eq!(contained_account_in(&host, "os.mail").as_deref(), Some("late")); } +/// The account is also asked before anything recorded the app's storage +/// block in this run (a glance card's chat thread at startup, before Mail +/// was opened or its agent prepared): the block is read from the manifest +/// then and recorded, so a signed-in Mail acts for its account, never the +/// device. +#[cfg(any(feature = "app-hub", native_mobile))] +#[test] +fn should_read_the_manifests_block_when_the_account_is_asked_first() { + let home = Scratch::new("account-first"); + let host = storage(&home.0); + let root = host.layout().apps_root().to_path_buf(); + write_json(&root.join(".system/os.mail/0001/manifest.json"), &json!({"id": "os.mail", "storage": {"accounts": true}})); + write_json(&root.join(".host/mail/accounts.json"), &json!([{"id": "ana@example.org", "apps": ["os.mail"], "signed_in": 10}])); + assert!(!host.has_spec("os.mail")); + assert_eq!(contained_account_in(&host, "os.mail").as_deref(), Some("ana@example.org")); + assert!(host.has_spec("os.mail") && host.spec("os.mail").accounts, "recorded from the manifest"); + // No manifest on disk yet (a system app not unpacked): the device, and + // nothing recorded, so a later ask reads it once it is there. + assert_eq!(contained_account_in(&host, "os.notes").as_deref(), Some("device")); + assert!(!host.has_spec("os.notes")); +} + // ---- script manifests, install and uninstall ----------------------------- #[test] diff --git a/crates/shell/src/app_storage/mod.rs b/crates/shell/src/app_storage/mod.rs index 8df9a380..1ee28f9a 100644 --- a/crates/shell/src/app_storage/mod.rs +++ b/crates/shell/src/app_storage/mod.rs @@ -434,6 +434,12 @@ impl Storage { self.state().specs.get(app_id).cloned().unwrap_or_default() } + /// Whether `app_id`'s storage block was recorded yet ([`Self::spec`] + /// answers the default until then). + pub fn has_spec(&self, app_id: &str) -> bool { + self.state().specs.contains_key(app_id) + } + /// Lay out `app_id`'s jail (`accounts/`, `common/`, `cache/`) and its /// secrets directory, and return the handle a module is offered. pub fn open(self: &Arc, app_id: &str) -> Result, StorageError> { diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 999b9107..580387fb 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -41,10 +41,13 @@ //! "l0:{e,k,v}", v?)` (Octoscript-Makepad's general translation). Every //! isolate gets a host `NAV` ([`install_nav`]) that queues the call with the //! isolate's heap key; nothing else reads it. The card window -//! (glance_sheet.rs) takes its own isolate's calls ([`take_taps`]) and runs -//! them through an [`L0Session`]: the declared transition (`octoscript_ui_l0` -//! dispatch), the §5.12 writes the host performs, and a re-lowering. A -//! glance-panel tile does not dispatch: its L0 taps stay inert. +//! (glance_sheet.rs) and the desktop's glance panel (glance_panel.rs) keep +//! their L0 cards in a [`LiveCards`], one path for both: a tile's own +//! isolate's calls ([`take_taps`], never another tile's) run through its +//! card's [`L0Session`], made for the app that published the card: the +//! declared transition (`octoscript_ui_l0` dispatch), the §5.12 writes the +//! host performs (a `sys.chat` append, glance_chat.rs), and a re-lowering. +//! The phone's glance page does not dispatch yet: its L0 taps stay inert. use makepad_widgets::*; use std::cell::RefCell; use std::collections::HashMap; @@ -254,20 +257,26 @@ pub fn install_nav(vm: &mut ScriptVm) { vm.bx.heap.cast_to_string(v, &mut text); text }); - let heap = vm.bx.heap.heap_key(); - if let Ok(mut taps) = TAPS.lock() { - // Bounded: a card no surface reads cannot grow it forever. - if taps.len() >= 64 { - taps.remove(0); - } - taps.push(Tap { heap, target, typed }); - } + queue_tap(Tap { heap: vm.bx.heap.heap_key(), target, typed }); NIL }) }; vm.set_injected_global(id!(NAV), nav.into()); } +/// The most `NAV` calls kept waiting: a card no surface reads cannot grow +/// the queue forever. +const TAPS_MAX: usize = 64; + +fn queue_tap(tap: Tap) { + if let Ok(mut taps) = TAPS.lock() { + if taps.len() >= TAPS_MAX { + taps.remove(0); + } + taps.push(tap); + } +} + /// Register [`install_nav`] for every isolate made from now on, once, with /// the hit target a lowered card's taps are drawn as (`OctoscriptTap`, which /// the Card runner's own isolate mods do not include). @@ -284,11 +293,28 @@ pub fn ensure_nav() { } } -/// The queued `NAV` calls from the isolate `heap`; every other isolate's -/// queued calls are dropped (nothing dispatches them). +/// The queued `NAV` calls from the isolate `heap`, in order, taken off the +/// queue. Every other isolate's calls stay for the surface whose tile it is +/// (two surfaces dispatch: the card window and the glance panel); those of +/// a tile no surface dispatches go when the queue is full, or when the tile +/// does ([`drop_taps`]). pub fn take_taps(heap: usize) -> Vec { - let taps = TAPS.lock().map(|mut t| std::mem::take(&mut *t)).unwrap_or_default(); - taps.into_iter().filter(|t| t.heap == heap).collect() + let Ok(mut taps) = TAPS.lock() else { return Vec::new() }; + if !taps.iter().any(|t| t.heap == heap) { + return Vec::new(); + } + let (mine, others) = std::mem::take(&mut *taps).into_iter().partition(|t| t.heap == heap); + *taps = others; + mine +} + +/// Forget the queued calls of the isolate `heap`, whose tile went away: a +/// heap key is unique only while its isolate lives, so a later isolate may +/// get the same one, and must not run the calls the old one queued. +pub fn drop_taps(heap: usize) { + if let Ok(mut taps) = TAPS.lock() { + taps.retain(|t| t.heap != heap); + } } /// `l0:{"e":event,"k":instance key,"v":value}` → `(key, event, value)`. @@ -298,10 +324,10 @@ pub fn parse_tap(target: &str) -> Option<(String, String, String)> { Some((field("k")?, field("e")?, field("v").unwrap_or_default())) } -/// A live L0 card in the card window: the app that published it, its -/// source, its data as published and its local state. Its `sys.chat` -/// sources are answered by the host on every lowering and dispatch -/// (glance_chat.rs), never from `data`. +/// A live L0 card (in the card window or a glance-panel tile): the app that +/// published it, its source, its data as published and its local state. Its +/// `sys.chat` sources are answered by the host on every lowering and +/// dispatch (glance_chat.rs), never from `data`. pub struct L0Session { pub app: String, pub source: String, @@ -309,6 +335,8 @@ pub struct L0Session { pub store: octoscript_ui_l0::InstanceStore, /// The chat generation the card was last lowered at. chat_generation: u64, + /// The card reads a `sys.chat` (worked out once: every event asks). + reads_chat: bool, } /// What a tap did to an [`L0Session`]. @@ -335,7 +363,14 @@ fn node_arg<'a>(node: &'a octoscript_ui_l0::UiNode, name: &str) -> Option<&'a oc impl L0Session { /// The card `app` published. pub fn new(app: &str, l0: &crate::glance::L0Source) -> Self { - L0Session { app: app.to_string(), source: l0.source.clone(), data: l0.data.clone(), store: Default::default(), chat_generation: crate::glance_chat::generation() } + L0Session { + app: app.to_string(), + source: l0.source.clone(), + data: l0.data.clone(), + store: Default::default(), + chat_generation: crate::glance_chat::generation(), + reads_chat: crate::glance_chat::reads_chat(&l0.source), + } } /// The data the card reads now: as published, with the host's answer @@ -353,7 +388,35 @@ impl L0Session { /// A conversation the card reads changed since it was last lowered (a /// reply arrived): lower it again. pub fn chat_moved(&self) -> bool { - self.chat_generation != crate::glance_chat::generation() && crate::glance_chat::reads_chat(&self.source) + self.reads_chat && self.chat_generation != crate::glance_chat::generation() + } + + /// Carry out one tile's queued `NAV` calls, in order, then lower the + /// card again when a tap, or a conversation the card reads (the agent's + /// reply), moved it: the dispatch the card window and the glance panel + /// share ([`LiveCards`]). The new body when the card changed; `who` + /// heads the log lines (`glance sheet: os.mail/ana-contract`). + pub fn run(&mut self, taps: Vec, who: &str) -> Option { + let mut relower = self.chat_moved(); + for tap in taps { + match self.tap(&tap.target, tap.typed.as_deref()) { + Ok(outcome) => { + log!("{who} tap {} (applied {}, relower {})", outcome.event, outcome.applied, outcome.relower); + relower |= outcome.relower; + } + Err(e) => log!("{who} tap refused: {e}"), + } + } + if !relower { + return None; + } + match self.body() { + Ok(body) => Some(body), + Err(e) => { + log!("{who} does not lower: {e}"); + None + } + } } /// Carry out one `NAV` call from this card. @@ -406,6 +469,79 @@ impl L0Session { } } +/// The L0 cards one surface keeps live, by tile key: each card's +/// [`L0Session`] and the body its tile draws now. The card window keeps one +/// for its card and the glance panel one for its tiles, so a tap runs the +/// same way on both (module docs, "L0 taps"). A script card is not kept +/// here: it runs as published and keeps its own state. +#[derive(Default)] +pub struct LiveCards { + cards: HashMap, +} + +struct LiveCard { + /// The card's key (`app/card_id`), for the log. + key: String, + /// The publish the session runs: a newer publish of the card starts over. + published: std::sync::Arc, + session: L0Session, + body: std::sync::Arc, +} + +impl LiveCards { + /// What tile `tile` draws for `card`: an L0 card as its session has it + /// now (made on first use for the app that published the card, and made + /// again for a newer publish), a script card as published. `who` heads + /// the log lines (`glance panel`). + pub fn body(&mut self, tile: &str, card: &crate::glance::GlanceCard, who: &str) -> std::sync::Arc { + let Some(l0) = &card.l0 else { + self.cards.remove(tile); + return card.body.clone(); + }; + if let Some(live) = self.cards.get(tile).filter(|live| std::sync::Arc::ptr_eq(&live.published, l0)) { + return live.body.clone(); + } + let mut session = L0Session::new(&card.app, l0); + let body: std::sync::Arc = match session.body() { + Ok(body) => body.into(), + Err(e) => { + log!("{who}: {} lowers as published only: {e}", card.key()); + card.body.clone() + } + }; + self.cards.insert(tile.to_string(), LiveCard { key: card.key(), published: l0.clone(), session, body: body.clone() }); + body + } + + /// Run each live card's queued taps, those of its own tile's isolate + /// only, through its session ([`L0Session::run`]). True when a card + /// changed: the surface redraws. + pub fn dispatch(&mut self, cx: &mut Cx, tiles: &GlanceTiles, who: &str) -> bool { + let mut changed = false; + for (tile, live) in &mut self.cards { + let taps = tiles.heap_key(cx, tile).map(take_taps).unwrap_or_default(); + if taps.is_empty() && !live.session.chat_moved() { + continue; + } + if let Some(body) = live.session.run(taps, &format!("{who}: {}", live.key)) { + live.body = body.into(); + changed = true; + } + } + changed + } + + /// Forget the cards whose tiles the surface no longer has (`live`: the + /// tile keys it keeps). + pub fn retain(&mut self, live: &[String]) { + self.cards.retain(|tile, _| live.contains(tile)); + } + + pub fn clear(&mut self) { + self.cards.clear(); + } +} + /// The tile height for a measured card height. pub fn clamp_height(measured: f64) -> f64 { measured.clamp(TILE_MIN_HEIGHT, TILE_MAX_HEIGHT) @@ -558,10 +694,12 @@ impl GlanceTiles { for key in gone { if let Some(tile) = self.tiles.remove(&key) { let splash = tile.frame.splash(cx, ids!(card)); - // Its waiting host requests end with it: a late answer goes - // nowhere, never to a tile that takes its place. - #[cfg(any(feature = "app-hub", native_mobile))] + // Its waiting host requests and taps end with it: a late + // answer goes nowhere, and neither reaches a tile that + // takes its place (it may get the same heap key). if let Some(heap) = splash.borrow_mut().and_then(|mut s| s.isolate_heap_key(cx)) { + drop_taps(heap); + #[cfg(any(feature = "app-hub", native_mobile))] octosense_appstore::services::cancel_heap(heap); } splash.set_text(cx, ""); @@ -888,6 +1026,122 @@ mod tests { assert!(mark > body.find("Q?").unwrap() && mark < body.find("A.").unwrap(), "the mark sits on the agent's entry: {body}"); } + /// Taps are taken per isolate: a surface takes its own tile's, in + /// order, and leaves every other tile's for that tile's surface; a tile + /// that goes away takes its queued taps with it. + #[test] + fn taps_are_taken_by_the_isolate_that_queued_them() { + const A: usize = 0x6a11_0001; + const B: usize = 0x6a11_0002; + let tap = |heap, target: &str| Tap { heap, target: target.into(), typed: None }; + queue_tap(tap(A, "a1")); + queue_tap(tap(B, "b1")); + queue_tap(tap(A, "a2")); + let mine: Vec = take_taps(A).into_iter().map(|t| t.target).collect(); + assert_eq!(mine, ["a1", "a2"]); + assert!(take_taps(A).is_empty(), "taken once"); + drop_taps(B); + assert!(take_taps(B).is_empty(), "a swept tile's taps go with it"); + } + + /// The glance panel's tiles dispatch as the card window does (both keep + /// a `LiveCards`): a tile's L0 taps, taken from its own isolate only, run + /// through its card's session for the app that published it; the tile + /// then draws the card as it stands. Ask shows the host's transcript, a + /// question typed and committed is the person's entry, the agent's reply + /// follows (the demo's canned one), and the card shows both. A newer + /// publish starts the card over; a card no longer shown is forgotten. + #[cfg(feature = "app-hub")] + #[test] + fn the_panels_tiles_run_their_own_l0_taps_like_the_card_window() { + use crate::glance::{Caller, GlanceStore}; + use crate::glance_chat::{Role, DEMO_ANSWER}; + crate::glance_chat::set_demo_mail(true); + // The request card on a thread of its own (tests share the store). + let (_, title, source, data) = crate::glance::demo_mail().into_iter().find(|c| c.0 == "ana-contract").unwrap(); + let source = source.replace("thread: \"ana-contract\"", "thread: \"panel-taps\""); + crate::glance_chat::store().seed_if_empty("os.mail", "panel-taps", &[(Role::User, "Earlier?"), (Role::Model, "Net 30.")], 0); + let publish = serde_json::json!({"card_id": "panel", "title": title, "source": source, "data": data}); + let mut store = GlanceStore::default(); + store.publish(&Caller::granted("os.mail"), &publish, 0).unwrap(); + let card = store.card("os.mail/panel", 0).unwrap(); + let key = card.key(); + + let mut cx = tile_cx(); + let mut tiles = GlanceTiles::default(); + let mut live = LiveCards::default(); + let body = live.body(&key, &card, "glance panel"); + assert!(body.contains("Reply") && body.contains("Ask") && !body.contains("Net 30."), "{body}"); + tiles.open(&mut cx, &key, "os.mail", false, &"View{}".into()); + tiles.open(&mut cx, "os.other/c", "os.other", false, &"View{}".into()); + let heap = tiles.heap_key(&mut cx, &key).unwrap(); + let other = tiles.heap_key(&mut cx, "os.other/c").unwrap(); + assert_ne!(heap, other); + + // Another tile's isolate asks for this card's Ask: not its tap. It + // stays queued for that tile's surface. + let ask = target_for(&body, "ask"); + queue_tap(Tap { heap: other, target: ask.clone(), typed: None }); + live.dispatch(&mut cx, &tiles, "glance panel"); + assert!(!live.body(&key, &card, "glance panel").contains("Net 30."), "this card did not move"); + assert_eq!(take_taps(other).len(), 1); + + // Its own tile's isolate does. + queue_tap(Tap { heap, target: ask, typed: None }); + assert!(live.dispatch(&mut cx, &tiles, "glance panel"), "the card changed"); + let asked = live.body(&key, &card, "glance panel"); + assert!(asked.contains("Earlier?") && asked.contains("Net 30."), "the host's transcript: {asked}"); + + // Return in the field sends what the person typed. + let (k, e, _) = targets(&asked).into_iter().find(|(k, e, _)| e == "submit" && k.contains("Field")).expect("the field commits"); + let commit = format!("l0:{}", serde_json::json!({"e": e, "k": k, "v": "$$"})); + queue_tap(Tap { heap, target: commit, typed: Some("When do they need it?".into()) }); + assert!(live.dispatch(&mut cx, &tiles, "glance panel")); + let entries = crate::glance_chat::store().entries("os.mail", "panel-taps"); + let last: Vec<(Role, &str)> = entries.iter().rev().take(2).rev().map(|e| (e.role, e.text.as_str())).collect(); + assert_eq!(last, [(Role::User, "When do they need it?"), (Role::Model, DEMO_ANSWER)]); + let answered = live.body(&key, &card, "glance panel"); + assert!(answered.contains("When do they need it?") && answered.contains(DEMO_ANSWER), "{answered}"); + + // A newer publish of the card starts it over, as published. + store.publish(&Caller::granted("os.mail"), &publish, 1).unwrap(); + let newer = store.card("os.mail/panel", 1).unwrap(); + assert!(!live.body(&key, &newer, "glance panel").contains(DEMO_ANSWER), "back to the brief"); + // A tile the surface drops takes its session and its queued taps. + queue_tap(Tap { heap: other, target: "l0:{}".into(), typed: None }); + tiles.sweep(&mut cx, std::slice::from_ref(&key)); + live.retain(&[]); + assert!(take_taps(other).is_empty()); + assert!(!live.body(&key, &newer, "glance panel").contains(DEMO_ANSWER)); + } + + /// Without the glance demo: a card with buttons that any app with the + /// `glance` grant publishes (here a store app) dispatches from its panel + /// tile, its taps running for that app. + #[cfg(feature = "app-hub")] + #[test] + fn a_card_with_buttons_dispatches_from_the_panel_without_the_demo() { + use crate::glance::{Caller, GlanceStore}; + let (_, _, source, data) = crate::glance::demo_mail().into_iter().find(|c| c.0 == "ups-lamp").unwrap(); + let mut store = GlanceStore::default(); + let shop = Caller::granted("com.example.shop"); + store.publish(&shop, &serde_json::json!({"card_id": "parcel", "title": "Your parcel", "source": source, "data": data}), 0).unwrap(); + let card = store.card("com.example.shop/parcel", 0).unwrap(); + let mut cx = tile_cx(); + let mut tiles = GlanceTiles::default(); + let mut live = LiveCards::default(); + let body = live.body(&card.key(), &card, "glance panel"); + tiles.open(&mut cx, &card.key(), &card.app, true, &"View{}".into()); + let heap = tiles.heap_key(&mut cx, &card.key()).unwrap(); + queue_tap(Tap { heap, target: target_for(&body, "track"), typed: None }); + assert!(live.dispatch(&mut cx, &tiles, "glance panel")); + let tracking = live.body(&card.key(), &card, "glance panel"); + assert!(tracking.contains("Opening the carrier's tracking page"), "{tracking}"); + queue_tap(Tap { heap, target: target_for(&tracking, "back"), typed: None }); + assert!(live.dispatch(&mut cx, &tiles, "glance panel")); + assert!(live.body(&card.key(), &card, "glance panel").contains("Track")); + } + /// The shipping card: carrier, status, ETA and Track; no reply. #[test] fn the_shipping_card_tracks_and_offers_no_reply() { diff --git a/crates/shell/src/glance_chat.rs b/crates/shell/src/glance_chat.rs index 0b521459..87145441 100644 --- a/crates/shell/src/glance_chat.rs +++ b/crates/shell/src/glance_chat.rs @@ -14,10 +14,13 @@ //! app's conversation (ADR 0004 §6: the person's lane on the host-owned //! app peer, `crate::agents::conversation`) once the person allowed it; //! otherwise a `host` notice saying why. Under `OCTOSENSE_GLANCE_DEMO=mail` -//! Mail, which has no agent yet (no `octos` services, no `tools.json`), -//! answers with the canned demo reply instead. -//! - **Stale.** Every change wakes the UI; the card window re-seeds and -//! re-lowers its card when [`generation`] moved (glance_sheet.rs). +//! Mail's chat answers with the canned demo reply ([`DEMO_ANSWER`]) by +//! design, although Mail has an agent (its `tools.json`, `mail.notify`): +//! the demo's cards and thread are fake data (glance.rs), so its chat +//! never asks a model. +//! - **Stale.** Every change wakes the UI; a surface that shows the card +//! (the card window, the glance panel) re-seeds and re-lowers it when +//! [`generation`] moved (glance_card.rs `LiveCards`). use octosense_l0_chat::{ChatStore, Done, Reply, Request, Responder}; use serde_json::Value; use std::sync::{Arc, Mutex, OnceLock}; @@ -39,11 +42,18 @@ pub fn store() -> &'static Arc { /// The app's chat folder in its account folder, created owner-only. fn folder(app: &str) -> Option { - let host = crate::app_storage::host()?; - let paths = host.layout().app(app).ok()?; - let account = crate::ai_host::contained::account_of(app).filter(|a| a != crate::ai_host::contained::ACCOUNT); + folder_in(crate::app_storage::host()?, app, crate::ai_host::contained::account_of(app)) +} + +/// `app`'s chat folder in `storage`, for `account`: the account its agent +/// acts for, as `account_of` answers it (the shell's lookup is +/// `app_storage::lifecycle::contained_account_in`); the device folder for +/// [`crate::ai_host::contained::ACCOUNT`] or none. +fn folder_in(storage: &crate::app_storage::Storage, app: &str, account: Option) -> Option { + let paths = storage.layout().app(app).ok()?; + let account = account.filter(|a| a != crate::ai_host::contained::ACCOUNT); let dir = paths.account(account.as_deref()).join("chat"); - match crate::app_storage::ensure_private_dir(host.layout().apps_root(), &dir) { + match crate::app_storage::ensure_private_dir(storage.layout().apps_root(), &dir) { Ok(()) => Some(dir), Err(e) => { makepad_widgets::log!("glance chat: {app}'s chat folder: {e}; kept in memory"); @@ -173,4 +183,37 @@ mod tests { other => panic!("{other:?}"), } } + + /// A card's chat thread is kept in the folder of the account the app's + /// agent acts for: a signed-in Mail's account, also for a thread first + /// touched at startup (the demo's seed), before anything opened Mail or + /// prepared its agent in this run. The account comes from Mail's + /// manifest then; before, the unrecorded block read as "no accounts" + /// and the thread went to the device folder. + #[cfg(any(feature = "app-hub", native_mobile))] + #[test] + fn a_cards_thread_is_kept_under_the_account_the_agent_acts_for() { + use crate::app_storage::{lifecycle::contained_account_in, Layout, Storage}; + let home = crate::app_storage::tests::Scratch::new("glance-chat-account"); + let host = Storage::with_file_secrets(Layout::new(&home.0).unwrap()); + let apps = host.layout().apps_root().to_path_buf(); + let write = |path: std::path::PathBuf, value: Value| { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, value.to_string()).unwrap(); + }; + write(apps.join(".system/os.mail/0001/manifest.json"), serde_json::json!({"id": "os.mail", "storage": {"accounts": true}})); + write(apps.join(".host/mail/accounts.json"), serde_json::json!([{"id": "ana@example.org", "apps": ["os.mail"], "signed_in": 10}])); + assert!(!host.has_spec("os.mail"), "nothing opened Mail in this run"); + let chat = { + let host = host.clone(); + ChatStore::with_folder(Box::new(move |app| folder_in(&host, app, contained_account_in(&host, app)))) + }; + assert!(chat.seed_if_empty("os.mail", "ana-contract", &[(Role::Model, "Net 30.")], 0)); + let mail = host.layout().app("os.mail").unwrap(); + assert!(mail.account(Some("ana@example.org")).join("chat/ana-contract.json").is_file()); + assert!(!mail.account(None).join("chat").exists(), "nothing in the device folder"); + // An app without accounts keeps its threads in its device folder. + assert!(chat.seed_if_empty("os.news", "main", &[(Role::Model, "Rates held.")], 0)); + assert!(host.layout().app("os.news").unwrap().account(None).join("chat/main.json").is_file()); + } } diff --git a/crates/shell/src/glance_panel.rs b/crates/shell/src/glance_panel.rs index 4b3b1c10..d42dc9b3 100644 --- a/crates/shell/src/glance_panel.rs +++ b/crates/shell/src/glance_panel.rs @@ -12,7 +12,7 @@ //! column is [`PANEL_WIDTH`] wide; each card is a tile of the column's inner //! width at its measured height (glance_card.rs), in the glance order //! (priority, then recency). -use crate::glance_card::GlanceTiles; +use crate::glance_card::{GlanceTiles, LiveCards}; use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, Ico, ShellDraw}; use crate::shell::{alpha, MaterialTokens, ShellTokens}; use makepad_widgets::*; @@ -78,6 +78,13 @@ pub struct ShellGlancePanel { logged: String, #[rust] tiles: GlanceTiles, + /// The L0 cards' live state, by card key, as the card window keeps its + /// card's (glance_card.rs `LiveCards`): their chips, buttons and field + /// edits run here as there, their in-card chat (`sys.chat`) with the + /// publishing app's own agent included. A card's state lasts while the + /// panel shows it; closing the panel forgets it. + #[rust] + live: LiveCards, /// The panel's own area: what `redraw` repaints (`draw_bg` draws /// nothing). #[redraw] @@ -159,7 +166,8 @@ impl Widget for ShellGlancePanel { } drawn += 1; let r = rect(x + PAD, y, PANEL_WIDTH - PAD * 2.0, h); - self.tiles.draw(cx, &key, &card.app, card.contained, &card.body, r); + let body = self.live.body(&key, card, "glance panel"); + self.tiles.draw(cx, &key, &card.app, card.contained, &body, r); let open = crate::glance_card::open_button(r); self.d.card(cx, open, &tok.notifications.surface); self.d.icon_centered(cx, Ico::ChevronRight, open, 14.0, ink); @@ -193,6 +201,7 @@ impl Widget for ShellGlancePanel { } let live: Vec = if self.open { crate::glance::shown().iter().map(|c| c.key()).collect() } else { Vec::new() }; self.tiles.sweep(cx, &live); + self.live.retain(&live); cx.end_turtle_with_area(&mut self.area); DrawStep::done() } @@ -254,6 +263,11 @@ impl Widget for ShellGlancePanel { // has swept its tiles; pointer events reach them only while open. if self.open || !event.requires_visibility() { self.tiles.handle_event(cx, event); + // Then the L0 taps those tiles queued, as the card window runs + // its card's, and the cards whose chat moved (a reply came). + if self.live.dispatch(cx, &self.tiles, "glance panel") { + self.redraw(cx); + } } } } diff --git a/crates/shell/src/glance_sheet.rs b/crates/shell/src/glance_sheet.rs index 8881c958..8e526e5e 100644 --- a/crates/shell/src/glance_sheet.rs +++ b/crates/shell/src/glance_sheet.rs @@ -9,18 +9,18 @@ //! scrolls inside. The card runs in its own isolate, under the publishing //! app's policy, as a glance tile does (glance_card.rs). //! -//! An L0 card is live here: its taps and field edits run through an -//! [`L0Session`] (the declared transition, the §5.12 writes this host -//! performs, a re-lowering), so a Reply opens its draft and a Send changes -//! the card. Its state lives as long as the window: closing it forgets it. -//! Its in-card chat (`sys.chat`) is the host's and outlives the window -//! (glance_chat.rs); the card is lowered again when the agent's reply -//! comes. -use crate::glance_card::{GlanceTiles, L0Session}; +//! An L0 card is live here, as in the glance panel ([`LiveCards`]): its +//! taps and field edits run through its `L0Session` (the declared +//! transition, the §5.12 writes this host performs, a re-lowering), so a +//! Reply opens its draft and a Send changes the card. Its state lives as +//! long as the window: closing it forgets it. Its in-card chat (`sys.chat`) +//! is the host's and outlives the window (glance_chat.rs); the card is +//! lowered again when the agent's reply comes. +use crate::glance::GlanceCard; +use crate::glance_card::{GlanceTiles, LiveCards}; use crate::shell::ui::{contains, rect, DrawShellFill, HAlign, Ico, ShellDraw}; use crate::shell::{alpha, MaterialTokens, ShellTokens}; use makepad_widgets::*; -use std::sync::Arc; pub const SHEET_WIDTH: f64 = 380.0; /// The window's least height, and its card's height before it is measured. @@ -63,15 +63,10 @@ pub fn card_rect(sheet: Rect) -> Rect { rect(sheet.pos.x + PAD, sheet.pos.y + HEADER, sheet.size.x - PAD * 2.0, sheet.size.y - HEADER - PAD) } -/// The open card. +/// The open card, as it was published when the window opened. struct Open { key: String, - app: String, - title: String, - contained: bool, - body: Arc, - /// An L0 card's live state; `None` for a script card (which keeps its own). - session: Option, + card: GlanceCard, } #[derive(Script, ScriptHook, Widget)] @@ -97,9 +92,15 @@ pub struct ShellGlanceSheet { sheet: Rect, #[rust] tiles: GlanceTiles, + /// An L0 card's live state (a script card keeps its own). + #[rust] + live: LiveCards, /// What the last layout log said, so it is logged once per change. #[rust] logged: String, + /// The window's own area: what `redraw` repaints (`draw_bg` draws + /// nothing), so an agent's reply that comes later shows at once. + #[redraw] #[rust] area: Area, } @@ -115,19 +116,13 @@ impl ShellGlanceSheet { let Some(card) = crate::glance::card(key) else { return false; }; - let mut session = card.l0.as_deref().map(|l0| L0Session::new(&card.app, l0)); - let body: Arc = match session.as_mut().map(L0Session::body) { - Some(Ok(body)) => body.into(), - Some(Err(e)) => { - log!("glance sheet: {key} lowers as published only: {e}"); - card.body.clone() - } - None => card.body.clone(), - }; - // A fresh isolate for each opening: the card starts as published. + // A fresh isolate and session for each opening: the card starts as + // published (lowered now, so one that does not lower says so once). self.tiles.sweep(cx, &[]); self.tiles = GlanceTiles::scrolling(); - self.open = Some(Open { key: key.to_string(), app: card.app.clone(), title: card.title.clone(), contained: card.contained, body, session }); + self.live.clear(); + self.live.body(&Self::tile_key(key), &card, "glance sheet"); + self.open = Some(Open { key: key.to_string(), card }); log!("glance sheet: opened {key}"); self.redraw(cx); true @@ -138,6 +133,7 @@ impl ShellGlanceSheet { log!("glance sheet: closed {}", open.key); } self.tiles.sweep(cx, &[]); + self.live.clear(); self.logged.clear(); self.redraw(cx); } @@ -156,29 +152,10 @@ impl ShellGlanceSheet { format!("sheet:{key}") } - /// Run the open card's queued taps through its L0 session. + /// Run the open card's queued taps through its L0 session, and lower it + /// again when the agent's reply came (glance_card.rs `LiveCards`). fn dispatch_taps(&mut self, cx: &mut Cx) { - let Some(open) = self.open.as_mut() else { return }; - let tile = Self::tile_key(&open.key); - let Some(heap) = self.tiles.heap_key(cx, &tile) else { return }; - let taps = crate::glance_card::take_taps(heap); - let Some(session) = open.session.as_mut() else { return }; - // A conversation the card reads moved (the agent's reply came). - let mut relower = session.chat_moved(); - for tap in taps { - match session.tap(&tap.target, tap.typed.as_deref()) { - Ok(outcome) => { - log!("glance sheet: {} tap {} (applied {}, relower {})", open.key, outcome.event, outcome.applied, outcome.relower); - relower |= outcome.relower; - } - Err(e) => log!("glance sheet: {} tap refused: {e}", open.key), - } - } - if relower { - match session.body() { - Ok(body) => open.body = body.into(), - Err(e) => log!("glance sheet: {} does not lower: {e}", open.key), - } + if self.open.is_some() && self.live.dispatch(cx, &self.tiles, "glance sheet") { self.redraw(cx); } } @@ -199,11 +176,12 @@ impl Widget for ShellGlanceSheet { self.sheet = sheet; self.d.card(cx, sheet, &tok.notifications.surface); let close = close_rect(sheet); - self.d.label_elided(cx, rect(sheet.pos.x + PAD + 4.0, sheet.pos.y + 4.0, sheet.size.x - PAD * 2.0 - CLOSE - 8.0, HEADER - 4.0), false, 12.0, alpha(ink, 0.7), HAlign::Left, &open.title); + self.d.label_elided(cx, rect(sheet.pos.x + PAD + 4.0, sheet.pos.y + 4.0, sheet.size.x - PAD * 2.0 - CLOSE - 8.0, HEADER - 4.0), false, 12.0, alpha(ink, 0.7), HAlign::Left, &open.card.title); self.d.icon_centered(cx, Ico::Close, close, 14.0, ink); let card = card_rect(sheet); - let (key, app, contained, body) = (Self::tile_key(&open.key), open.app.clone(), open.contained, open.body.clone()); - self.tiles.draw(cx, &key, &app, contained, &body, card); + let key = Self::tile_key(&open.key); + let body = self.live.body(&key, &open.card, "glance sheet"); + self.tiles.draw(cx, &key, &open.card.app, open.card.contained, &body, card); let layout = format!("{} sheet@{},{},{},{} card@{},{},{},{} close@{},{}", open.key, sheet.pos.x as i32, sheet.pos.y as i32, sheet.size.x as i32, sheet.size.y as i32, card.pos.x as i32, card.pos.y as i32, card.size.x as i32, card.size.y as i32, (close.pos.x + close.size.x * 0.5) as i32, (close.pos.y + close.size.y * 0.5) as i32); if layout != self.logged { log!("glance sheet: {layout}"); diff --git a/desktop/README.md b/desktop/README.md index e4d7c4cc..ee4d530e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -118,7 +118,7 @@ App Hub's modules have no process form and always open in-process. | `OCTOSENSE_LLM_VAULT=file` | Keep AI providers' keys in the owner-only octos profile instead of the macOS keychain. | | `OCTOS_APP_CORE_BIN`, `OCTOS_APP_CORE_DIR` | The octos kernel binary the shell's kernel service runs (none: no kernel on this desktop) and its core dir (default `~/octos-home/.octos`; the AI providers profile is `/profiles/_main.json`). | | `OCTOSENSE_GLANCE_DEMO=1` | Publish a sample L0 News digest card (as `os.news`) to the glance screen at startup: F9 on a desktop style, the glance page on a phone style. A test path for the `glance` service. | -| `OCTOSENSE_GLANCE_DEMO=mail` | Publish two fake Mail action cards (L0, as `os.mail`, with a toast each) at startup: clicking a toast opens that card in the card window, where Reply, Send (demo), Ask and Track work on fake data. No mail is read and no model is called. `scripts/mail_card_remote.sh` drives it hidden. | +| `OCTOSENSE_GLANCE_DEMO=mail` | Publish two fake Mail action cards (L0, as `os.mail`, with a toast each) at startup: clicking a toast opens that card in the card window, where Reply, Send (demo), Ask and Track work on fake data. They work the same in the glance panel, which opens with them. No mail is read and no model is called. `scripts/mail_card_remote.sh` drives it hidden. | | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). | ## The app model diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 10f4ba6e..21590403 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -118,7 +118,7 @@ App Hub 的模块没有进程形态,总是在进程内打开。 | `OCTOSENSE_LLM_VAULT=file` | 把 AI 提供商的密钥保存在仅所有者可读的 octos profile 中,而不是 macOS 钥匙串。 | | `OCTOS_APP_CORE_BIN`、`OCTOS_APP_CORE_DIR` | Shell 内核服务运行的 octos 内核二进制(未设置:此桌面上没有内核)及其 core 目录(默认 `~/octos-home/.octos`;AI 提供商的 profile 为 `/profiles/_main.json`)。 | | `OCTOSENSE_GLANCE_DEMO=1` | 启动时以 `os.news` 身份向一览屏发布一张示例 L0 新闻摘要卡片:桌面风格下按 F9 查看,手机风格下在一览页查看。用于测试 `glance` 服务。 | -| `OCTOSENSE_GLANCE_DEMO=mail` | 启动时以 `os.mail` 身份发布两张假的邮件操作卡片(L0,各带一条通知):点击通知会在卡片窗口中打开对应卡片,可用假数据试用回复、发送(演示)、提问和跟踪。不读取邮件,也不调用模型。`scripts/mail_card_remote.sh` 以隐藏窗口驱动它。 | +| `OCTOSENSE_GLANCE_DEMO=mail` | 启动时以 `os.mail` 身份发布两张假的邮件操作卡片(L0,各带一条通知):点击通知会在卡片窗口中打开对应卡片,可用假数据试用回复、发送(演示)、提问和跟踪。在随卡片打开的一览面板里,它们同样可用。不读取邮件,也不调用模型。`scripts/mail_card_remote.sh` 以隐藏窗口驱动它。 | | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 | ## 应用模型