Skip to content

Commit e5d537e

Browse files
ymoteclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 749581a commit e5d537e

12 files changed

Lines changed: 564 additions & 111 deletions

File tree

‎crates/l0-chat/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Octoscript's L0 checker admits the card. The rest is the host's, and is here
2020
| **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. |
2121
| **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. |
2222
| **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_-]`. |
23-
| **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/<app>/accounts/<account>/chat/<thread>.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/`. |
23+
| **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/<app>/accounts/<account>/chat/<thread>.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/`. |
2424
| **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. |
2525

2626
## Who answers: `Responder`

‎crates/l0-chat/src/lib.rs‎

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
//! - **Storage.** Threads are kept per app and thread, in the folder the
3636
//! host's [`ChatStore::with_folder`] names for the app (the shell: the
3737
//! app's account folder under ADR 0004's layout, `apps/<app>/accounts/
38-
//! <account>/chat/<thread>.json`), or in memory.
38+
//! <account>/chat/<thread>.json`), or in memory. A reply is kept in the
39+
//! thread its message went to, even when the host names another folder
40+
//! by then (another account signed in while the agent answered).
3941
//! - **Stale.** Every change bumps [`ChatStore::generation`] and calls the
4042
//! host's change hook, so a surface re-seeds and re-lowers the card: the
4143
//! write marks the source stale (§5.9) and the reply arrives later.
@@ -255,8 +257,8 @@ impl ChatStore {
255257
.unwrap_or_default()
256258
}
257259

258-
fn save(&self, app: &str, thread: &str, t: &Thread) {
259-
let Some(path) = self.file(app, thread) else {
260+
fn save(&self, app: &str, thread: &str, file: &Option<PathBuf>, t: &Thread) {
261+
let Some(path) = file else {
260262
return;
261263
};
262264
let disk = json!({"schema": SCHEMA, "entries": t.entries, "next": t.next});
@@ -267,31 +269,48 @@ impl ChatStore {
267269
use std::os::unix::fs::PermissionsExt;
268270
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
269271
}
270-
std::fs::rename(&tmp, &path)
272+
std::fs::rename(&tmp, path)
271273
});
272274
if let Err(e) = written {
273275
eprintln!("l0 chat: could not keep {app}/{thread}: {e}");
274276
}
275277
}
276278

277-
fn with_thread<R>(&self, app: &str, thread: &str, f: impl FnOnce(&mut Thread) -> R) -> R {
279+
/// `f` on the thread in the folder the host names for the app now.
280+
/// Keyed by the file too: when the host's folder for the app changes
281+
/// (another account is active), the thread is that folder's.
282+
fn with_thread<R>(
283+
&self,
284+
app: &str,
285+
thread: &str,
286+
f: impl FnOnce(&mut Thread, &Option<PathBuf>) -> R,
287+
) -> R {
288+
self.with_thread_at(app, thread, self.file(app, thread), f)
289+
}
290+
291+
/// `f` on the thread kept in `file` (`None`: in memory), and that file.
292+
fn with_thread_at<R>(
293+
&self,
294+
app: &str,
295+
thread: &str,
296+
file: Option<PathBuf>,
297+
f: impl FnOnce(&mut Thread, &Option<PathBuf>) -> R,
298+
) -> R {
278299
let mut threads = self.threads.lock().unwrap_or_else(|e| e.into_inner());
279-
// Keyed by the file too: when the host's folder for the app changes
280-
// (another account is active), the thread is that folder's.
281-
let key = (app.to_string(), thread.to_string(), self.file(app, thread));
300+
let key = (app.to_string(), thread.to_string(), file);
282301
if !threads.contains_key(&key) {
283302
let loaded = self.load(&key.2);
284303
threads.insert(key.clone(), loaded);
285304
}
286-
f(threads.get_mut(&key).expect("just inserted"))
305+
f(threads.get_mut(&key).expect("just inserted"), &key.2)
287306
}
288307

289308
/// The thread's entries, oldest first.
290309
pub fn entries(&self, app: &str, thread: &str) -> Vec<Entry> {
291310
if !valid_app(app) || !valid_thread(thread) {
292311
return Vec::new();
293312
}
294-
self.with_thread(app, thread, |t| t.entries.clone())
313+
self.with_thread(app, thread, |t, _| t.entries.clone())
295314
}
296315

297316
/// `sys.chat`'s answer for one thread: `{status, count, entries}`;
@@ -300,7 +319,7 @@ impl ChatStore {
300319
if !valid_app(app) || !valid_thread(thread) {
301320
return unavailable();
302321
}
303-
self.with_thread(app, thread, |t| {
322+
self.with_thread(app, thread, |t, _| {
304323
json!({
305324
"status": if t.answering { "answering" } else { "ready" },
306325
"count": t.entries.len(),
@@ -317,6 +336,18 @@ impl ChatStore {
317336
thread: &str,
318337
text: &str,
319338
now: u64,
339+
) -> Result<Entry, String> {
340+
self.append_user_at(app, thread, self.file(app, thread), text, now)
341+
}
342+
343+
/// [`Self::append_user`] to the thread kept in `file`.
344+
fn append_user_at(
345+
&self,
346+
app: &str,
347+
thread: &str,
348+
file: Option<PathBuf>,
349+
text: &str,
350+
now: u64,
320351
) -> Result<Entry, String> {
321352
if !valid_app(app) || !valid_thread(thread) {
322353
return Err(format!("not a conversation: {app}/{thread}"));
@@ -331,7 +362,7 @@ impl ChatStore {
331362
text.len()
332363
));
333364
}
334-
let entry = self.with_thread(app, thread, |t| {
365+
let entry = self.with_thread_at(app, thread, file, |t, file| {
335366
if t.answering {
336367
return Err("the agent is still answering the last message".to_string());
337368
}
@@ -346,7 +377,7 @@ impl ChatStore {
346377
let entry = push(t, Role::User, text, now);
347378
t.last_user_ms = Some(now);
348379
t.answering = true;
349-
self.save(app, thread, t);
380+
self.save(app, thread, file, t);
350381
Ok(entry)
351382
})?;
352383
self.changed();
@@ -356,6 +387,18 @@ impl ChatStore {
356387
/// The host appends the agent's reply (`model`) or a notice (`host`),
357388
/// and the thread takes messages again.
358389
pub fn append_reply(&self, app: &str, thread: &str, reply: Reply, now: u64) -> Option<Entry> {
390+
self.append_reply_at(app, thread, self.file(app, thread), reply, now)
391+
}
392+
393+
/// [`Self::append_reply`] to the thread kept in `file`.
394+
fn append_reply_at(
395+
&self,
396+
app: &str,
397+
thread: &str,
398+
file: Option<PathBuf>,
399+
reply: Reply,
400+
now: u64,
401+
) -> Option<Entry> {
359402
if !valid_app(app) || !valid_thread(thread) {
360403
return None;
361404
}
@@ -367,10 +410,10 @@ impl ChatStore {
367410
Reply::Notice(text) => (Role::Host, text),
368411
};
369412
let text = cut(text.trim(), REPLY_MAX_BYTES);
370-
let entry = self.with_thread(app, thread, |t| {
413+
let entry = self.with_thread_at(app, thread, file, |t, file| {
371414
let entry = push(t, role, text, now);
372415
t.answering = false;
373-
self.save(app, thread, t);
416+
self.save(app, thread, file, t);
374417
entry
375418
});
376419
self.changed();
@@ -389,14 +432,14 @@ impl ChatStore {
389432
if !valid_app(app) || !valid_thread(thread) {
390433
return false;
391434
}
392-
let seeded = self.with_thread(app, thread, |t| {
435+
let seeded = self.with_thread(app, thread, |t, file| {
393436
if !t.entries.is_empty() {
394437
return false;
395438
}
396439
for (role, text) in entries {
397440
push(t, *role, text.to_string(), now);
398441
}
399-
self.save(app, thread, t);
442+
self.save(app, thread, file, t);
400443
true
401444
});
402445
if seeded {
@@ -618,8 +661,12 @@ pub fn perform(
618661
));
619662
}
620663
let thread = thread_of(&source, state, data).ok_or("sys.chat: no valid thread")?;
621-
let entry = chat.append_user(&app, &thread, &write.value, now)?;
622-
let history = chat.entries(&app, &thread);
664+
// The reply goes where the message went: the thread in the folder the
665+
// host names now, even when it names another (another account signed
666+
// in) before the agent answers.
667+
let file = chat.file(&app, &thread);
668+
let entry = chat.append_user_at(&app, &thread, file.clone(), &write.value, now)?;
669+
let history = chat.with_thread_at(&app, &thread, file.clone(), |t, _| t.entries.clone());
623670
let store = chat.clone();
624671
let (a, t) = (app.clone(), thread.clone());
625672
responder.respond(
@@ -630,7 +677,7 @@ pub fn perform(
630677
history,
631678
},
632679
Box::new(move |reply| {
633-
store.append_reply(&a, &t, reply, now_ms());
680+
store.append_reply_at(&a, &t, file, reply, now_ms());
634681
}),
635682
);
636683
Ok(entry)

‎crates/l0-chat/src/tests.rs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,3 +498,75 @@ fn threads_are_kept_in_the_apps_folder() {
498498
.is_empty()
499499
);
500500
}
501+
502+
/// A thread is the folder's the host names for the app now: another
503+
/// account signed in, another thread. A reply goes to the thread its
504+
/// message went to, even when the account switched while the agent
505+
/// answered: never into the new account's thread, and the first thread
506+
/// takes messages again.
507+
#[test]
508+
fn a_reply_stays_with_the_account_its_message_went_to() {
509+
struct Later(Mutex<Option<Done>>);
510+
impl Responder for Later {
511+
fn respond(&self, _: Request, done: Done) {
512+
*self.0.lock().unwrap() = Some(done);
513+
}
514+
}
515+
let root = std::env::temp_dir().join(format!("l0-chat-accounts-{}", std::process::id()));
516+
let _ = std::fs::remove_dir_all(&root);
517+
let account = Arc::new(Mutex::new("a"));
518+
let folder = {
519+
let (root, account) = (root.clone(), account.clone());
520+
move |app: &str| {
521+
let dir = root.join(app).join(*account.lock().unwrap()).join("chat");
522+
std::fs::create_dir_all(&dir).ok()?;
523+
Some(dir)
524+
}
525+
};
526+
let chat = Arc::new(ChatStore::with_folder(Box::new(folder)));
527+
let later = Later(Mutex::new(None));
528+
let (write, origin, data) = send(&chat, "os.news", CARD, "asked as a");
529+
perform(
530+
&chat,
531+
&later,
532+
"os.news",
533+
CARD,
534+
&InstanceStore::default(),
535+
&data,
536+
&write,
537+
origin,
538+
0,
539+
)
540+
.unwrap();
541+
// Another account signs in while the agent answers.
542+
*account.lock().unwrap() = "b";
543+
assert!(chat.entries("os.news", "main").is_empty(), "b's own thread");
544+
let done = later.0.lock().unwrap().take().unwrap();
545+
done(Reply::Model("for a".into()));
546+
assert!(
547+
chat.entries("os.news", "main").is_empty(),
548+
"a's reply is not b's"
549+
);
550+
assert!(!root.join("os.news/b/chat/main.json").exists());
551+
*account.lock().unwrap() = "a";
552+
assert_eq!(
553+
roles(&chat, "os.news", "main"),
554+
[
555+
(Role::User, "asked as a".to_string()),
556+
(Role::Model, "for a".to_string())
557+
]
558+
);
559+
assert_eq!(
560+
chat.answer("os.news", "main")["status"],
561+
"ready",
562+
"a's thread takes messages again"
563+
);
564+
let kept = ChatStore::with_folder(Box::new(move |app: &str| {
565+
Some(root.join(app).join("a").join("chat"))
566+
}));
567+
assert_eq!(
568+
kept.entries("os.news", "main").len(),
569+
2,
570+
"kept on disk as a's"
571+
);
572+
}

‎crates/shell/src/app_storage/lifecycle.rs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,20 @@ pub fn account_changed(storage: &Arc<Storage>, service_app: &str, previous: Opti
171171
/// opened): record its manifest's storage block, so its agent acts for the
172172
/// right account and its suspension is keyed right; then `prepare`.
173173
pub fn prepare_agent_with<R>(storage: &Storage, root: &Path, app_id: &str, prepare: impl FnOnce(&Storage) -> R) -> R {
174+
record_manifest_spec(storage, root, app_id);
175+
prepare(storage)
176+
}
177+
178+
/// Record the storage block of the script app `app_id`'s manifest, when
179+
/// one is on disk ([`script_manifest`]); a block the host refuses is
180+
/// logged, and the default holds.
181+
fn record_manifest_spec(storage: &Storage, root: &Path, app_id: &str) {
174182
if let Some(manifest) = script_manifest(root, app_id) {
175183
match StorageSpec::from_manifest(&manifest, AppKind::Script) {
176184
Ok(spec) => storage.set_spec(app_id, spec),
177185
Err(e) => makepad_widgets::log!("app storage: {app_id}: {e}"),
178186
}
179187
}
180-
prepare(storage)
181188
}
182189

183190
/// The account a contained app's agent acts for (`contained::set_account_of`).
@@ -192,7 +199,17 @@ pub fn contained_account(app: &str) -> Option<String> {
192199
/// (Mail), its active account (the one the person signed in to last, from
193200
/// Mail's host service), or none yet. One agent is live per app at a time,
194201
/// bound to that account (ADR 0004 §11).
202+
///
203+
/// Whether the app keeps accounts is its manifest's storage block. Nothing
204+
/// may have recorded it yet in this run: the account is also asked before
205+
/// the app was opened or its agent prepared (a glance card's chat thread at
206+
/// startup, glance_chat.rs), so the block is read from the manifest then,
207+
/// as [`prepare_agent_with`] does. The default, no accounts, would answer
208+
/// the device for a signed-in Mail.
195209
pub fn contained_account_in(storage: &Storage, app: &str) -> Option<String> {
210+
if !storage.has_spec(app) {
211+
record_manifest_spec(storage, storage.layout().apps_root(), app);
212+
}
196213
if !storage.spec(app).accounts {
197214
return Some(crate::ai_host::contained::ACCOUNT.to_owned());
198215
}

‎crates/shell/src/app_storage/lifecycle_tests.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,28 @@ fn should_bind_a_contained_agent_to_the_active_account_when_the_app_keeps_accoun
265265
assert_eq!(contained_account_in(&host, "os.mail").as_deref(), Some("late"));
266266
}
267267

268+
/// The account is also asked before anything recorded the app's storage
269+
/// block in this run (a glance card's chat thread at startup, before Mail
270+
/// was opened or its agent prepared): the block is read from the manifest
271+
/// then and recorded, so a signed-in Mail acts for its account, never the
272+
/// device.
273+
#[cfg(any(feature = "app-hub", native_mobile))]
274+
#[test]
275+
fn should_read_the_manifests_block_when_the_account_is_asked_first() {
276+
let home = Scratch::new("account-first");
277+
let host = storage(&home.0);
278+
let root = host.layout().apps_root().to_path_buf();
279+
write_json(&root.join(".system/os.mail/0001/manifest.json"), &json!({"id": "os.mail", "storage": {"accounts": true}}));
280+
write_json(&root.join(".host/mail/accounts.json"), &json!([{"id": "ana@example.org", "apps": ["os.mail"], "signed_in": 10}]));
281+
assert!(!host.has_spec("os.mail"));
282+
assert_eq!(contained_account_in(&host, "os.mail").as_deref(), Some("ana@example.org"));
283+
assert!(host.has_spec("os.mail") && host.spec("os.mail").accounts, "recorded from the manifest");
284+
// No manifest on disk yet (a system app not unpacked): the device, and
285+
// nothing recorded, so a later ask reads it once it is there.
286+
assert_eq!(contained_account_in(&host, "os.notes").as_deref(), Some("device"));
287+
assert!(!host.has_spec("os.notes"));
288+
}
289+
268290
// ---- script manifests, install and uninstall -----------------------------
269291

270292
#[test]

‎crates/shell/src/app_storage/mod.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,12 @@ impl Storage {
434434
self.state().specs.get(app_id).cloned().unwrap_or_default()
435435
}
436436

437+
/// Whether `app_id`'s storage block was recorded yet ([`Self::spec`]
438+
/// answers the default until then).
439+
pub fn has_spec(&self, app_id: &str) -> bool {
440+
self.state().specs.contains_key(app_id)
441+
}
442+
437443
/// Lay out `app_id`'s jail (`accounts/`, `common/`, `cache/`) and its
438444
/// secrets directory, and return the handle a module is offered.
439445
pub fn open(self: &Arc<Self>, app_id: &str) -> Result<Arc<dyn AppStorage>, StorageError> {

0 commit comments

Comments
 (0)