diff --git a/Cargo.lock b/Cargo.lock index a5519168..b06c0a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6852,7 +6852,7 @@ dependencies = [ [[package]] name = "octos-agent" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -6982,7 +6982,7 @@ dependencies = [ [[package]] name = "octos-bus" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "aes 0.8.4", "async-trait", @@ -7011,7 +7011,7 @@ dependencies = [ [[package]] name = "octos-cli" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "agent-client-protocol", "argon2", @@ -7092,7 +7092,7 @@ dependencies = [ [[package]] name = "octos-core" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", @@ -7106,7 +7106,7 @@ dependencies = [ [[package]] name = "octos-diagnostics" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7118,7 +7118,7 @@ dependencies = [ [[package]] name = "octos-fleet" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "octos-core", @@ -7134,7 +7134,7 @@ dependencies = [ [[package]] name = "octos-fleet-worker" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "eyre", @@ -7151,7 +7151,7 @@ dependencies = [ [[package]] name = "octos-llm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "base64 0.22.1", @@ -7175,7 +7175,7 @@ dependencies = [ [[package]] name = "octos-memory" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "bincode", "chrono", @@ -7194,7 +7194,7 @@ dependencies = [ [[package]] name = "octos-pipeline" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7216,7 +7216,7 @@ dependencies = [ [[package]] name = "octos-plugin" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "eyre", "metrics", @@ -7230,7 +7230,7 @@ dependencies = [ [[package]] name = "octos-research" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chromiumoxide", @@ -7254,7 +7254,7 @@ dependencies = [ [[package]] name = "octos-sandbox" version = "0.1.0" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "clap", "eyre", @@ -7269,7 +7269,7 @@ dependencies = [ [[package]] name = "octos-services" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "dirs", @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "octos-store" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "base64 0.22.1", "chrono", @@ -7311,7 +7311,7 @@ dependencies = [ [[package]] name = "octos-swarm" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "async-trait", "chrono", @@ -7330,7 +7330,7 @@ dependencies = [ [[package]] name = "octos-workflows" version = "2.0.3-rc.13" -source = "git+https://github.com/octos-org/octos.git?rev=ae230ce04d57f3c29cf6c2518e5956a86c07d788#ae230ce04d57f3c29cf6c2518e5956a86c07d788" +source = "git+https://github.com/octos-org/octos.git?rev=056173e85b150e387805fc307fe231064ac1ed35#056173e85b150e387805fc307fe231064ac1ed35" dependencies = [ "chrono", "eyre", diff --git a/Cargo.toml b/Cargo.toml index 8653183d..5108d045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,12 @@ octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub" # and App Hub and Rinx no longer need the same App Hub commit. octosense-app-contract = "1" # --- octos: ONE source for every octos crate (octos-org/octos main at the -# merge of octos#2656, low fixes for host-owned app peers; after #2655, the +# merge of octos#2675, hydrate rows that name a tool row's call and tool; +# after #2672, the accepted client_commands echoed on session/open, #2671 +# and #2669, docs, #2665, undeclared secrets kept from strict skills, #2664, +# octos's own dev profile, and #2261, a credential store that keeps +# credentials on a failed save; before that #2656, low fixes for host-owned +# app peers; #2655, the # unknown-outcome interlock kept across a kernel restart, #2653, a bound # workspace swapped for a symlink no longer re-roots the session, #2648, a # host session's exact tool list; #2649, @@ -120,12 +125,12 @@ octosense-app-contract = "1" # keeps its peer/input, and #2645, Bing News's Chinese results). # Move octos-core, octos-cli and octos-research together; the Android kernel # artifact (liboctos.so) is built from this same revision. -octos-core = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788" } -octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-core = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35" } +octos-cli = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # The research engine (the metasearch, the SSRF-safe polite reader and the # toolbox scope) for the toolbox's `octos-engine` feature. Same source and # revision as above. -octos-research = { git = "https://github.com/octos-org/octos.git", rev = "ae230ce04d57f3c29cf6c2518e5956a86c07d788", default-features = false } +octos-research = { git = "https://github.com/octos-org/octos.git", rev = "056173e85b150e387805fc307fe231064ac1ed35", default-features = false } # --- AppCard's inline renderers. streaming-markdown-kit = { git = "https://github.com/Project-Robius-China/streaming-markdown-kit.git", branch = "main" } makepad-diagram-kit = { git = "https://github.com/Project-Robius-China/makepad-diagram-kit.git", branch = "main" } diff --git a/README.md b/README.md index 5858307f..b614364d 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ The limits are 6 publishes per minute and 4 cards per app. The shell keeps 32 ca | Surface | What happens | | --- | --- | -| Desktop panel | A new card opens the glance panel unless a card window is already open. The bar’s bell or F9 also opens the panel. A press on a card outside its own controls opens it in the card window. The hovered card shows its open and dismiss actions, and a card that just came wears an accent mark for a few seconds. A card that does not fit whole peeks in at the end of the list. Opened with F9 (or a press in it), the panel has the keyboard: the arrows move a focus ring from card to card, Return opens the card, Delete dismisses it and Esc closes the panel. | +| Desktop panel | A new card opens the glance panel unless a card window is already open. The bar’s bell or F9 also opens the panel. A press on a card outside its own controls opens it in the card window. The hovered card shows its open and dismiss actions, and a card that just came wears an accent mark for a few seconds. A card that does not fit whole peeks in at the end of the list. A card taller than its tile scrolls inside it with the wheel; a card with an in-card chat stays at its newest rows, so its field and the latest exchange stay in view. Opened with F9 (or a press in it), the panel has the keyboard: the arrows move a focus ring from card to card, Return opens the card, Delete dismisses it and Esc closes the panel. | | Desktop notification | A card published with `notify` also posts a toast with the app’s icon and name, the card’s title and its `summary` (else the card’s own). Selecting the toast opens the card in its own window. At most three toasts show at once; a “+N more” chip under them shows the rest. While the panel is open, toasts stack to its left. | | Desktop dismissal | The hovered card shows a dismiss button: `glance::dismiss` removes the card as if the app had withdrawn it. Clear all dismisses every card. A dismissal can be undone from the toast that reports it, or with ⌘Z while the panel has the keyboard. The panel has a separate close button. | | Phone | `notify` posts a shade notification. Selecting it opens the glance page. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c743b65..ad7c8646 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -261,7 +261,7 @@ sequenceDiagram | 界面 | 行为 | | --- | --- | -| 桌面面板 | 新卡片会打开 glance 面板,除非已有卡片窗口打开。顶栏铃铛或 F9 也可以打开面板。点击卡片上自身控件以外的地方,会在卡片窗口中打开它。鼠标悬停的卡片会显示打开和移除操作,刚到的卡片旁会有几秒钟的强调色标记。放不下的那张卡片会在列表末尾露出一部分。用 F9(或在面板里点击)打开时,面板接管键盘:方向键在卡片之间移动焦点环,Return 打开卡片,Delete 移除卡片,Esc 关闭面板。 | +| 桌面面板 | 新卡片会打开 glance 面板,除非已有卡片窗口打开。顶栏铃铛或 F9 也可以打开面板。点击卡片上自身控件以外的地方,会在卡片窗口中打开它。鼠标悬停的卡片会显示打开和移除操作,刚到的卡片旁会有几秒钟的强调色标记。放不下的那张卡片会在列表末尾露出一部分。卡片在面板里有高度上限,更高的卡片可以用滚轮在原处滚动;带卡内对话的卡片停在最新的消息处,输入框和最近一轮对话始终可见。用 F9(或在面板里点击)打开时,面板接管键盘:方向键在卡片之间移动焦点环,Return 打开卡片,Delete 移除卡片,Esc 关闭面板。 | | 桌面通知 | 以 `notify` 发布的卡片还会弹出 toast,显示应用的图标和名称、卡片标题及其 `summary`(没有时用卡片自带的摘要)。点击 toast 会在独立窗口中打开卡片。同时最多显示三条 toast,其余的由下方的“+N more”标签展开。面板打开时,toast 叠放在面板左侧。 | | 桌面关闭操作 | 鼠标悬停的卡片会显示移除按钮:`glance::dismiss` 会移除卡片,效果如同应用撤回了它。“Clear all”会移除所有卡片。移除的卡片可以在报告这次移除的 toast 上撤销(Undo),面板接管键盘时也可以按 ⌘Z 撤销。面板本身另有关闭按钮。 | | 手机 | `notify` 在通知栏发出通知,点击后打开 glance 页面。 | diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index 360847b4..78482254 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -60,6 +60,9 @@ struct Io { /// host-managed server stops when it reaches EOF, which also happens /// when this process dies, so a crashed shell leaves no kernel behind. lifeline: Option, + /// The task copying the child's stderr into the [`Tail`]; it ends at + /// the end of the pipe, after the kernel's last line. + stderr: Option>, } /// Keeps the last lines the kernel wrote to stderr, to say why it exited. @@ -116,7 +119,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu return Err("the kernel's stdin/stdout are not piped".into()); }; // The kernel logs to stderr; it never carries protocol frames. - if let Some(stderr) = child.stderr.take() { + let stderr = child.stderr.take().map(|stderr| { let log = log.clone(); let tail = tail.clone(); tokio::spawn(async move { @@ -126,14 +129,14 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu (log)(&format!("octos: {line}")); tail.push(line); } - }); - } + }) + }); let lines = BufReader::new(Box::pin(stdout) as Pin>).lines(); Ok(if shared { // Frames go over the WebSocket once it is up (`prepare_network`). - Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin) } + Io { writer: Box::pin(tokio::io::sink()), lines, running: Running::Child(child), lifeline: Some(stdin), stderr } } else { - Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None } + Io { writer: Box::pin(stdin), lines, running: Running::Child(child), lifeline: None, stderr } }) } #[cfg(target_env = "ohos")] @@ -153,6 +156,7 @@ fn start(launch: &Launch, network: &Network, log: &LogSink, tail: &Tail) -> Resu lines: BufReader::new(Box::pin(reader) as Pin>).lines(), running: Running::Embedded(task), lifeline: None, + stderr: None, }) } #[cfg(not(target_env = "ohos"))] @@ -266,7 +270,7 @@ async fn exited(running: &mut Running) -> String { async fn stop(io: Io) { // Closing stdin stops either kind of child: the stdio pipe drains owned // turns on EOF; a host-managed server drains its connections and exits. - let Io { writer, lines, mut running, lifeline } = io; + let Io { writer, lines, mut running, lifeline, .. } = io; let grace = if lifeline.is_some() { SHARED_GRACE } else { STDIO_GRACE }; drop(writer); drop(lines); @@ -401,16 +405,18 @@ pub(crate) async fn supervise( } } } - Ok(None) => break CloseReason::Exited(with_tail("the kernel closed its output", &tail)), + Ok(None) => break CloseReason::Exited("the kernel closed its output".into()), Err(e) => break CloseReason::Exited(format!("reading from the kernel failed: {e}")), }, - why = exited(&mut io.running) => break CloseReason::Exited(with_tail(&why, &tail)), + why = exited(&mut io.running) => break CloseReason::Exited(why), } }}; (log)(&format!("octos-core: stopping kernel {generation}: {reason}")); + let stderr = io.stderr.take(); stop(io).await; match reason { - CloseReason::Failed(why) => CloseReason::Failed(with_tail(&why, &tail)), + CloseReason::Failed(why) => CloseReason::Failed(last_words(&why, stderr, &tail).await), + CloseReason::Exited(why) => CloseReason::Exited(last_words(&why, stderr, &tail).await), other => other, } } @@ -464,6 +470,24 @@ fn tool_list_reply(text: &str) -> Option { }) } +/// How long a stopped kernel's stderr may take to reach its end. The pipe +/// closes with the kernel; only a process it started that inherited the +/// pipe can hold it open longer. +const LAST_WORDS_GRACE: Duration = Duration::from_secs(2); + +/// `why` with the kernel's last words (the [`Tail`]), once `stderr`, the +/// task reading them, has reached the end of the pipe. That task runs +/// beside the supervisor, so lines the kernel wrote just before its stdout +/// closed or it exited can still be on their way to the tail: read at once, +/// the reason said "the kernel closed its output: fake kernel up" without +/// the "asked to exit" the kernel wrote last. +async fn last_words(why: &str, stderr: Option>, tail: &Tail) -> String { + if let Some(reader) = stderr { + let _ = tokio::time::timeout(LAST_WORDS_GRACE, reader).await; + } + with_tail(why, tail) +} + fn with_tail(why: &str, tail: &Tail) -> String { let tail = tail.text(); if tail.is_empty() { diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index 2a74ba25..43d9d545 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -238,7 +238,9 @@ async fn a_kernel_that_dies_closes_its_connections_with_its_last_words() { call(&mut a, "1", "session/list", json!({})).await; a.send(json!({"jsonrpc": "2.0", "method": "test/exit", "params": {}}).to_string()).unwrap(); match next(&mut a).await { - Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit") || why.contains("exit"), "{why}"), + // The line it wrote to stderr last, whichever end the core saw + // first: its output closing or its exit status. + Err(CloseReason::Exited(why)) => assert!(why.contains("asked to exit"), "{why}"), other => panic!("expected Exited, got {other:?}"), } for _ in 0..100 { diff --git a/crates/shell/src/approvals/mod.rs b/crates/shell/src/approvals/mod.rs index 33168e40..0d1a0fd2 100644 --- a/crates/shell/src/approvals/mod.rs +++ b/crates/shell/src/approvals/mod.rs @@ -130,6 +130,31 @@ impl Approvals { pub fn consent_granted(&self, app: &str) -> bool { self.consent.granted(app, self.router.hooks().grants_all(app)) } + /// The relay's entry point (octos#2567) on these approvals: a call + /// needs an approval. + pub fn approval_requested(&mut self, app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { + self.router.approval_requested(app, tool, args, caller, context) + } + /// The relay installs itself; decisions made before are handed over + /// first. + pub fn set_relay(&mut self, mut relay: Box) { + for (id, decision, reason) in self.queue.take() { + relay.approval_decided(&id, decision, &reason); + } + *self.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); + } + /// The AI bus's held `confirm: host` call as a request like any other. + pub fn bus_requested(&mut self, held: &crate::ai_bus::HeldCall) -> Route { + let mut tool = ToolSpec::host(&held.tool); + tool.auto_approvable = held.auto_approvable; + if held.command { + tool = tool.command(); + } + let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); + // The pane is the person's own conversation with the system agent. + let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; + self.approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + } } static STATE: Mutex> = Mutex::new(None); @@ -160,34 +185,23 @@ pub fn now() -> u64 { // ------------------------------------------------------------ the relay -/// The relay's entry point (octos#2567): a call needs an approval. +/// The relay's entry point (octos#2567): a call needs an approval +/// ([`Approvals::approval_requested`] on the shell's approvals). pub fn approval_requested(app: &str, tool: ToolSpec, args: serde_json::Value, caller: Caller, context: RequestContext) -> Route { - with(|a| a.router.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) + with(|a| a.approval_requested(app, tool, args, caller, context)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } -/// The relay installs itself; decisions made before are handed over first. -pub fn set_relay(mut relay: Box) { - with(|a| { - for (id, decision, reason) in a.queue.take() { - relay.approval_decided(&id, decision, &reason); - } - *a.external.lock().unwrap_or_else(|e| e.into_inner()) = Some(relay); - }); +/// The relay installs itself; decisions made before are handed over first +/// ([`Approvals::set_relay`] on the shell's approvals). +pub fn set_relay(relay: Box) { + with(|a| a.set_relay(relay)); } /// The AI bus's `confirm: host` calls (`ai_bus::Route::Approval`): each is /// a request like any other; the shell drains the answers here and /// releases the held call (`AiBus::release`). pub fn bus_requested(held: &crate::ai_bus::HeldCall) -> Route { - let mut tool = ToolSpec::host(&held.tool); - tool.auto_approvable = held.auto_approvable; - if held.command { - tool = tool.command(); - } - let args = serde_json::from_str(&held.args).unwrap_or_else(|_| serde_json::Value::String(held.args.clone())); - // The pane is the person's own conversation with the system agent. - let context = RequestContext { call_id: held.key.clone(), trigger: Trigger::Person, ..RequestContext::default() }; - approval_requested(&held.app, tool, args, Caller::SystemAgent, context) + with(|a| a.bus_requested(held)).unwrap_or_else(|| Route::Refused("approvals are not set up".into())) } pub fn take_bus_decisions() -> Vec<(RequestId, Decision, String)> { diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 6b41ace1..329ae884 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -993,32 +993,31 @@ fn only_settings_and_the_sheet_make_a_person_gesture() { // ---------------------------------------------------------------- the relay seam +/// On approvals of its own, never the shell's (`super::with`): those are +/// the process's, and the tests that run app agents keep their consent +/// and relay there while this runs. #[test] fn the_relay_gets_decisions_made_before_it_was_installed() { - super::init_memory(); - let route = super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); - assert!(matches!(route, Route::Sheet(_))); - let (sheet, id) = super::with(|a| { + let mut a = super::Approvals::memory(); + let front = |a: &super::Approvals| { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + }; + let route = a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 1}), Caller::SystemAgent, ctx("g1", Trigger::Person)); + assert!(matches!(route, Route::Sheet(_))); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Deny, &ApprovalGesture::sheet_tap(), T0).unwrap(); let relay = RecordingRelay::default(); - super::set_relay(Box::new(relay.clone())); + a.set_relay(Box::new(relay.clone())); assert_eq!(relay.take(), vec![(RequestId("g1".into()), Decision::Deny, "denied on the sheet".into())]); // From now on, straight to it. - super::approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); - let (sheet, id) = super::with(|a| { - let s = a.router.front_sheet().unwrap(); - (s.id, s.lines[0].request.clone()) - }) - .unwrap(); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.approval_requested("os.mail", ToolSpec::host("mail.archive").not_auto_approvable(), json!({"id": 2}), Caller::SystemAgent, ctx("g2", Trigger::Person)); + let (sheet, id) = front(&a); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert_eq!(relay.take().len(), 1); // The AI bus's held `confirm: host` calls come back to the shell, not // to the relay: the Terminal's `run` asks the person, never a rule. - super::with(|a| a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0)).unwrap().unwrap(); + a.router.create_rule(&ApprovalGesture::settings_tap(), RuleDraft::everything("terminal", 30), T0).unwrap(); let held = crate::ai_bus::HeldCall { key: "bus:w4:c1".into(), app: "terminal".into(), @@ -1027,19 +1026,18 @@ fn the_relay_gets_decisions_made_before_it_was_installed() { auto_approvable: false, command: true, }; - assert!(matches!(super::bus_requested(&held), Route::Sheet(_))); - let (sheet, id, caller, args) = super::with(|a| { + assert!(matches!(a.bus_requested(&held), Route::Sheet(_))); + let (sheet, id, caller, args) = { let s = a.router.front_sheet().unwrap(); (s.id, s.lines[0].request.clone(), s.lines[0].caller.clone(), s.lines[0].args.join(" ")) - }) - .unwrap(); + }; assert_eq!(id, RequestId("bus:w4:c1".into())); assert_eq!(caller, "The system agent"); assert_eq!(args, "command (1 line): 1 \u{2502} ls", "a command, one numbered row per line"); - super::with(|a| a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0)).unwrap().unwrap(); + a.router.answer(sheet, &id, Answer::Once, &ApprovalGesture::sheet_tap(), T0).unwrap(); assert!(relay.take().is_empty()); - assert_eq!(super::take_bus_decisions(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); - assert!(!super::consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); + assert_eq!(a.bus.take(), vec![(id, Decision::ApproveOnce, "approved on the sheet".into())]); + assert!(!a.consent_granted("os.news"), "developer mode is off in tests: nothing is granted"); } /// The module host's gate: the first ask shows the first-use sheet and diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 5cba914d..64b30f32 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -141,6 +141,32 @@ pub fn system_card_apps() -> Vec { Vec::new() } +/// System apps a test registers for itself (the glance tile tests' +/// probes). They join App Hub's registry, the one [`system_card_apps`] +/// lists, which is process-wide and never forgets an app, so a test of the +/// build's own catalog saw them or not depending on which tests had run +/// before it in the same process. A test registers its app here, and the +/// catalog tests leave these out. +#[cfg(test)] +pub(crate) mod test_system_apps { + use std::sync::Mutex; + + static IDS: Mutex> = Mutex::new(Vec::new()); + + /// Register `app` for a test. It is noted first, so a catalog that + /// lists it was read after it was noted. + #[cfg(feature = "app-hub")] + pub(crate) fn register(app: octosense_appstore::system::SystemApp) { + IDS.lock().unwrap_or_else(|e| e.into_inner()).push(app.id); + octosense_appstore::system::register_system_app(app); + } + + /// Whether a test registered the system app `id` (an `os.` id). + pub(crate) fn is_test_app(id: &str) -> bool { + IDS.lock().unwrap_or_else(|e| e.into_inner()).contains(&id) + } +} + /// The services contained apps call through `host.request` (ADR 0004) /// that are not the assistant's, registered once, before the first system /// app can open: `mail` keeps accounts and passwords for the Mail app; @@ -658,12 +684,19 @@ mod tests { .collect() } + /// The build's own rows among `rows`: without the system apps other + /// tests registered for themselves ([`super::test_system_apps`]). + /// `rows` must be read before this looks, as an argument is. + fn build_rows(rows: Vec) -> Vec { + rows.into_iter().filter(|row| !card_manifest_id(row).is_some_and(super::test_system_apps::is_test_app)).collect() + } + #[cfg(feature = "mobile-apps")] #[test] fn bundled_apps_open_without_catalog_files_or_child_processes() { use makepad_widgets::*; let _one_rinx = crate::module_host::RINX_INSTANCE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let catalog = bundled_catalog(); + let catalog = build_rows(bundled_catalog()); // The linked modules in link order (AppCard is opt-in, `app-appcard`, // not part of `mobile-apps`; `settings` is the phone product's), then // the system apps no native module of the same id replaces. @@ -718,7 +751,7 @@ mod tests { let mut cx = Cx::new(Box::new(|_, _| {})); cx.with_vm(makepad_widgets::script_mod); let mut host = crate::module_host::ModuleHost::default(); - for (index, app) in bundled_catalog().iter().enumerate() { + for (index, app) in build_rows(bundled_catalog()).iter().enumerate() { let module = registry.module(&app.id).unwrap(); let client = index as u64 + 1; host.create(&mut cx, client, module, module_open(module, app).unwrap(), dvec2(400.0, 700.0)).unwrap(); @@ -768,7 +801,7 @@ mod tests { fn the_system_apps_ship_as_card_apps() { let registry = AppRegistry::default(); let native = registry.linked_ids(); - let ids: Vec = system_card_apps().into_iter().map(|app| app.id).collect(); + let ids: Vec = build_rows(system_card_apps()).into_iter().map(|app| app.id).collect(); let expected: Vec<&str> = system_app_ids().iter().copied().filter(|id| !native.contains(id)).collect(); assert_eq!(ids, expected); for id in &ids { diff --git a/crates/shell/src/glance_card.rs b/crates/shell/src/glance_card.rs index 7716a342..62a19ef0 100644 --- a/crates/shell/src/glance_card.rs +++ b/crates/shell/src/glance_card.rs @@ -16,8 +16,10 @@ //! the desktop panel's 328 pt). Height: the card's own measured height, //! clamped to [`TILE_MIN_HEIGHT`]..=[`TILE_MAX_HEIGHT`] (room for a whole //! action card, its buttons included); until the first draw measures it, -//! [`TILE_DEFAULT_HEIGHT`]. A taller card is clipped at the -//! cap; the app is one tap away. A script card should size its root `Fit`. +//! [`TILE_DEFAULT_HEIGHT`]. A taller card is clipped at the cap ([`overflow`] +//! says by how much), or scrolled inside its tile where the surface offers +//! that ([`GlanceTiles::draw_scrolled`]: the glance panel); the app is one +//! tap away. A script card should size its root `Fit`. //! //! **Policy.** A tile's isolate runs under the publishing app's resolved //! policy, applied exactly as the Card runner applies it @@ -323,6 +325,13 @@ pub fn take_taps(heap: usize) -> Vec { mine } +/// Whether the isolate `heap` has a click waiting: a call with no text (a +/// tap target's; a field's carries its text). A tap target claims no press, +/// so this is how a surface learns that a press was on one (glance_panel.rs). +pub fn has_clicks(heap: usize) -> bool { + TAPS.lock().is_ok_and(|taps| taps.iter().any(|t| t.heap == heap && t.typed.is_none())) +} + /// 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. @@ -579,6 +588,12 @@ pub fn clamp_height(measured: f64) -> f64 { measured.clamp(TILE_MIN_HEIGHT, TILE_MAX_HEIGHT) } +/// How much taller than its tile a card is, as last measured: what of it the +/// tile cannot show at once (0 when it fits). +pub fn overflow(key: &str) -> f64 { + measured_height(key).map_or(0.0, |m| (m - tile_height(key)).max(0.0)) +} + thread_local! { /// Measured tile heights by card key (`app/card_id`), shared by every /// surface that draws the card (phone glance page, desktop panel). @@ -644,12 +659,26 @@ impl GlanceTiles { /// when it changed. The first draw seats the isolate under `app`'s /// policy (module docs). pub fn draw(&mut self, cx: &mut Cx2d, key: &str, app: &str, contained: bool, body: &std::sync::Arc, rect: Rect) { + self.draw_scrolled(cx, key, app, contained, body, rect, 0.0); + } + + /// [`Self::draw`], the card scrolled `scroll` points up inside its tile, + /// so a card taller than its tile shows its part from there down (the + /// glance panel's, glance_panel.rs `TileScroll`). The frame's own layout + /// scrolls, not a scroll view: no scroll bar claims a press, so a press + /// a card's controls do not claim stays unclaimed, and the card's hits + /// stay where it is drawn. Tiles that scroll by themselves (the card + /// window's) ignore it. + pub fn draw_scrolled(&mut self, cx: &mut Cx2d, key: &str, app: &str, contained: bool, body: &std::sync::Arc, rect: Rect, scroll: f64) { if !CAN_RENDER { return; } ensure_vocabulary(cx); let splash = self.open(cx, key, app, contained, body); let Some(tile) = self.tiles.get_mut(key) else { return }; + if !self.scroll { + tile.frame.as_view().set_scroll_pos(cx, dvec2(0.0, scroll.max(0.0))); + } let walk = Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), ..Walk::default() }; let mut scope = Scope::empty(); // Inside the card's isolate, as the Card runner draws its card. @@ -839,7 +868,8 @@ mod tests { } /// A system app, registered from a pack made on the fly, whose manifest - /// grants `glance` and `storage` and nothing else. + /// grants `glance` and `storage` and nothing else. It is registered as + /// a test's, so the catalog tests running beside these leave it out. #[cfg(feature = "app-hub")] fn register_test_app(id: &'static str) { let dir = std::env::temp_dir().join(format!("glance-tile-app-{id}-{}", std::process::id())); @@ -852,7 +882,7 @@ mod tests { ); std::fs::write(dir.join("manifest.json"), manifest).unwrap(); let pack = serde_json::to_string(&octosense_app_hub::pack::pack_dir(&dir).unwrap()).unwrap(); - octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); + crate::apps::test_system_apps::register(octosense_appstore::system::SystemApp { id, name: "Glance test", pack: Box::leak(pack.into_boxed_str()), assets: &[] }); } /// A Cx with the widgets and the tile frame registered, for driving @@ -1058,6 +1088,23 @@ mod tests { assert!(mark > body.find("Q?").unwrap() && mark < body.find("A.").unwrap(), "the mark sits on the agent's entry: {body}"); } + /// A click waiting for an isolate is seen without taking it; a field's + /// edit (it carries text) is no click, and another isolate's is not its. + #[test] + fn a_waiting_click_is_seen_without_taking_it() { + const A: usize = 0x6a11_0011; + const B: usize = 0x6a11_0012; + queue_tap(Tap { heap: A, target: "a".into(), typed: Some("typed".into()) }); + assert!(!has_clicks(A), "a field's edit is not a click"); + queue_tap(Tap { heap: B, target: "b".into(), typed: None }); + assert!(!has_clicks(A), "another isolate's click"); + queue_tap(Tap { heap: A, target: "a".into(), typed: None }); + assert!(has_clicks(A)); + assert_eq!(take_taps(A).len(), 2, "seen, not taken"); + assert!(!has_clicks(A)); + drop_taps(B); + } + /// 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. @@ -1237,6 +1284,17 @@ mod tests { assert!(take_replies_for(&[heap]).is_empty(), "nothing is left queued for the tile that went away"); } + /// A card taller than its tile overflows by the difference, as last + /// measured; one that fits, or that never drew, by nothing. + #[test] + fn a_card_taller_than_its_tile_overflows_by_the_difference() { + record_height("os.overflow/tall", TILE_MAX_HEIGHT + 80.0); + assert_eq!(overflow("os.overflow/tall"), 80.0); + record_height("os.overflow/short", 200.0); + assert_eq!(overflow("os.overflow/short"), 0.0); + assert_eq!(overflow("os.overflow/never"), 0.0); + } + #[test] fn heights_clamp_to_the_tile_range() { assert_eq!(clamp_height(10.0), TILE_MIN_HEIGHT); diff --git a/crates/shell/src/glance_panel.rs b/crates/shell/src/glance_panel.rs index b2103758..ead1b9d7 100644 --- a/crates/shell/src/glance_panel.rs +++ b/crates/shell/src/glance_panel.rs @@ -10,7 +10,11 @@ //! //! - **A card's own controls** (its chips, buttons and fields) work in place. //! A press anywhere else on it opens the card in the card window -//! ([`ShellGlancePanelAction::OpenCard`]), as its notification does. +//! ([`ShellGlancePanelAction::OpenCard`]), as its notification does. A +//! field claims its press; a chip (a tap target, `OctoscriptTap`) claims +//! none and calls its card once the release's handlers have run, so a +//! release is held for a frame: a click from that card means its control +//! had it. //! - **The hovered card** shows its actions over its top-right corner, on a //! backdrop: open the app that published it, and dismiss the card. They //! are not drawn over every card's content all the time. @@ -22,6 +26,14 @@ //! ([`first_to_reveal`]). "More above" heads the list and "more below" //! ends it; the card that does not fit whole peeks in above that line, //! cut at the list's end. +//! - **A card taller than its tile** (the cap, glance_card.rs) scrolls +//! inside it ([`TileScroll`]), with a thumb at its edge. A card with an +//! in-card chat (`sys.chat`) sits at its newest rows and follows them as +//! the conversation grows, so its field and the latest exchange stay in +//! view; any other card starts at its top. The wheel over such a card +//! scrolls it first and moves the list only at its ends. The tile's own +//! layout scrolls, so a press the card's controls do not claim still +//! opens the card window. //! - **A dismissal** (a card's close, Delete, Clear all) can be undone: the //! shell's toast offers Undo ([`ShellGlancePanelAction::Dismissed`]), and //! ⌘Z does it while the panel holds the keyboard. @@ -42,6 +54,7 @@ use crate::glance_card::{GlanceTiles, LiveCards}; use crate::shell::ui::{contains, rect, slide, DrawShellFill, HAlign, Ico, ShellDraw}; use crate::shell::{alpha, MaterialTokens, ShellTokens}; use makepad_widgets::*; +use std::collections::HashMap; pub const PANEL_WIDTH: f64 = 380.0; const PAD: f64 = 16.0; @@ -98,6 +111,60 @@ pub fn focus_after_dismiss(keys: &[String], i: usize) -> Option { keys.get(i + 1).or(i.checked_sub(1).and_then(|j| keys.get(j))).cloned() } +/// Where a card taller than its tile sits in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TileScroll { + /// At its newest rows, its bottom, followed as the card grows: where a + /// card with an in-card chat starts, so its field and the latest + /// exchange stay in view. + Newest, + /// This far down from its top: where any other card starts (at 0), and + /// where the wheel leaves a chat scrolled back from its newest rows. + Top(f64), +} + +impl TileScroll { + /// Where a card starts: one with a chat at its newest rows, any other at + /// its top. + pub fn start(chat: bool) -> Self { + if chat { TileScroll::Newest } else { TileScroll::Top(0.0) } + } + + /// How far up the card is scrolled in a tile it overflows by `overflow`. + pub fn offset(self, overflow: f64) -> f64 { + let overflow = overflow.max(0.0); + match self { + TileScroll::Newest => overflow, + TileScroll::Top(down) => down.clamp(0.0, overflow), + } + } + + /// The wheel's `dy` (positive: further down the card) over a card that + /// overflows its tile by `overflow`: it scrolls as far as it goes, and a + /// chat back at its bottom follows its newest rows again. False when it + /// was at that end already: the wheel is the list's. + pub fn wheel(&mut self, dy: f64, overflow: f64, chat: bool) -> bool { + let overflow = overflow.max(0.0); + let at = self.offset(overflow); + let to = (at + dy).clamp(0.0, overflow); + if (to - at).abs() < 0.5 { + return false; + } + *self = if chat && to >= overflow - 0.5 { TileScroll::Newest } else { TileScroll::Top(to) }; + true + } +} + +/// A tall card's place in its tile, as the panel keeps it while the card is +/// drawn tall: the publish it is for (a newer one starts over), whether the +/// card has a chat, and where it sits. +#[derive(Clone, Copy, Debug)] +struct TallCard { + published_ms: u64, + chat: bool, + at: TileScroll, +} + script_mod! { use mod.prelude.widgets_internal.* use mod.widgets.* @@ -165,6 +232,12 @@ pub struct ShellGlancePanel { hover: Option, #[rust] card_press: Option, + /// That press released on its card: it opens the card window on the next + /// frame unless the card clicked by then (one of its chips had it). + #[rust] + card_release: Option, + #[rust] + release_frame: NextFrame, /// A new card came: the next draw scrolls as little as shows it. #[rust] reveal_newest: bool, @@ -196,6 +269,15 @@ pub struct ShellGlancePanel { /// What the last layout log said, so it is logged once per change. #[rust] logged: String, + /// The cards taller than their tiles: where each sits (by key), the ones + /// this frame drew with how far each overflows, and what the last log of + /// their places said. + #[rust] + tall: HashMap, + #[rust] + tall_drawn: Vec<(String, f64)>, + #[rust] + tall_logged: String, #[rust] tiles: GlanceTiles, /// The L0 cards' live state, by card key, as the card window keeps its @@ -316,6 +398,32 @@ impl ShellGlancePanel { fn card_at(&self, p: DVec2) -> Option { self.card_rects.iter().find(|(r, ..)| contains(*r, p)).map(|(.., key)| key.clone()) } + /// How far up the tall card `card` is scrolled in its tile, which it + /// overflows by `overflow`: where it sits, kept from frame to frame for + /// its publish (first seen: a card with a chat at its newest rows, any + /// other at its top), and noted as drawn for the wheel. + fn tall_scroll(&mut self, key: &str, card: &crate::glance::GlanceCard, overflow: f64) -> f64 { + let fresh = || { + let chat = card.l0.as_ref().is_some_and(|l0| crate::glance_chat::reads_chat(&l0.source)); + TallCard { published_ms: card.published_ms, chat, at: TileScroll::start(chat) } + }; + let tall = self.tall.entry(key.to_string()).or_insert_with(fresh); + if tall.published_ms != card.published_ms { + *tall = fresh(); + } + let at = tall.at.offset(overflow); + self.tall_drawn.push((key.to_string(), overflow)); + at + } + /// The wheel's `dy` at `p`: a tall card there scrolls first. False when + /// none is there, or it is at its end that way: the wheel is the list's. + fn scroll_tall(&mut self, p: DVec2, dy: f64) -> bool { + let Some(key) = self.card_at(p) else { return false }; + let Some(overflow) = self.tall_drawn.iter().find(|(k, _)| *k == key).map(|(_, o)| *o) else { return false }; + let Some(tall) = self.tall.get_mut(&key) else { return false }; + let chat = tall.chat; + tall.at.wheel(dy, overflow, chat) + } pub fn toggle(&mut self, cx: &mut Cx) { self.set_open(!self.open); self.redraw(cx); @@ -335,6 +443,7 @@ impl Widget for ShellGlancePanel { cx.begin_turtle(walk, self.layout); let screen = cx.turtle().rect(); self.card_rects.clear(); + self.tall_drawn.clear(); self.close = Rect::default(); // Every frame, open or closed: the kit keeps its overlay in tree order. self.d.begin_surface(cx); @@ -431,7 +540,17 @@ impl Widget for ShellGlancePanel { } let r = rect(x + PAD, y, PANEL_WIDTH - PAD * 2.0, h); let body = self.live.body(&key, card, "glance panel"); - self.tiles.draw(cx, &key, &card.app, card.contained, &body, r); + // A card taller than its tile scrolls inside it (one that + // peeks in shows its top), with a thumb at its edge, under + // the card's actions. + let overflow = if cut { 0.0 } else { crate::glance_card::overflow(&key) }; + let scroll = if overflow > 0.0 { self.tall_scroll(&key, card, overflow) } else { 0.0 }; + self.tiles.draw_scrolled(cx, &key, &card.app, card.contained, &body, r, scroll); + if overflow > 0.0 { + let track = rect(r.pos.x + r.size.x - 6.0, r.pos.y + 40.0, 3.0, (r.size.y - 50.0).max(12.0)); + let thumb = (track.size.y * r.size.y / (r.size.y + overflow)).clamp(12.0, track.size.y); + self.d.solid(cx, rect(track.pos.x, track.pos.y + (track.size.y - thumb) * scroll / overflow, track.size.x, thumb), alpha(ink, 0.35)); + } // A card that just came: an accent mark in the gutter // beside it for a few seconds, the one its toast is about. let age = now.saturating_sub(card.published_ms); @@ -486,9 +605,30 @@ impl Widget for ShellGlancePanel { log!("glance panel: {} card(s) {}", self.card_rects.len(), layout); self.logged = layout; } + // Where the tall cards sit, once per change: evidence for a remote run. + let places: Vec = self + .tall_drawn + .iter() + .filter_map(|(key, overflow)| { + let tall = self.tall.get(key)?; + let newest = if tall.at == TileScroll::Newest { " newest" } else { "" }; + Some(format!("{key} {:.0}/{:.0}{newest}", tall.at.offset(*overflow), overflow)) + }) + .collect(); + let places = places.join(", "); + if self.open && !self.sliding && places != self.tall_logged { + if !places.is_empty() { + log!("glance panel: scrolled {places}"); + } + self.tall_logged = places; + } let live: Vec = if self.open { crate::glance::listed().iter().map(|c| c.key()).collect() } else { Vec::new() }; self.tiles.sweep(cx, &live); self.live.retain(&live); + // A card that is not tall now (it fits, peeks in or is scrolled out + // of the list) starts over when it is again. + let tall_drawn = &self.tall_drawn; + self.tall.retain(|key, _| tall_drawn.iter().any(|(k, _)| k == key)); cx.end_turtle_with_area(&mut self.area); DrawStep::done() } @@ -506,9 +646,15 @@ impl Widget for ShellGlancePanel { self.new_timer = Timer::empty(); self.redraw(cx); } - // The wheel over the column: a card at a time. + // The wheel over the column: a card at a time, after a tall card + // under it has scrolled to its end that way. if let Event::Scroll(e) = event { if self.open && contains(self.column, e.abs) { + if self.scroll_tall(e.abs, e.scroll.y) { + self.wheel = 0.0; + self.redraw(cx); + return; + } self.wheel += e.scroll.y; let mut moved = false; while self.wheel >= WHEEL_STEP { @@ -605,13 +751,29 @@ impl Widget for ShellGlancePanel { Event::MouseUp(e) => { if let Some(key) = self.card_press.take() { if self.card_at(e.abs).as_deref() == Some(key.as_str()) { - log!("wm: glance card {key} opens in the card window"); - cx.widget_action(self.uid, ShellGlancePanelAction::OpenCard { key }); + // A chip claims no press and clicks once this + // release's handlers have run: wait a frame. + self.card_release = Some(key); + self.release_frame = cx.new_next_frame(); } } } _ => {} } + // The released card clicked: the press was one of its chips', + // not the card's. Seen before its taps run, on any event after + // the release's. + if let Some(key) = self.card_release.as_deref() { + if self.tiles.heap_key(cx, key).is_some_and(crate::glance_card::has_clicks) { + self.card_release = None; + } + } + if self.release_frame.is_event(event).is_some() { + if let Some(key) = self.card_release.take() { + log!("wm: glance card {key} opens in the card window"); + cx.widget_action(self.uid, ShellGlancePanelAction::OpenCard { key }); + } + } // 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") { @@ -651,6 +813,41 @@ mod tests { assert_eq!(step_focus(3, None, 7, true), 2, "a stale first is clamped"); } + /// A chat card past its tile's cap (after the first exchange) sits at + /// its newest rows, so its field and the latest exchange show, and stays + /// there as the conversation grows; any other card shows its top. + #[test] + fn a_chat_card_taller_than_its_tile_shows_its_newest_rows() { + let chat = TileScroll::start(true); + assert_eq!(chat.offset(80.0), 80.0, "scrolled up by all it overflows: its bottom shows"); + assert_eq!(chat.offset(140.0), 140.0, "a reply came: still at the newest rows"); + assert_eq!(chat.offset(0.0), 0.0, "it fits: nothing to scroll"); + let other = TileScroll::start(false); + assert_eq!((other.offset(80.0), other.offset(140.0)), (0.0, 0.0), "its top, as before"); + } + + /// The wheel over a tall card scrolls it as far as it goes and is the + /// list's at the card's ends; a chat scrolled back to its bottom follows + /// its newest rows again, and one scrolled up stays where it was read. + #[test] + fn the_wheel_scrolls_a_tall_card_and_leaves_the_list_its_ends() { + let mut at = TileScroll::start(true); + assert!(!at.wheel(40.0, 80.0, true), "at the newest rows already: the list's"); + assert!(at.wheel(-30.0, 80.0, true)); + assert_eq!(at, TileScroll::Top(50.0)); + assert_eq!(at.offset(120.0), 50.0, "a reply while it was scrolled back: the rows read stay put"); + assert!(at.wheel(-100.0, 80.0, true)); + assert_eq!(at, TileScroll::Top(0.0)); + assert!(!at.wheel(-10.0, 80.0, true), "at its top: the list's"); + assert!(at.wheel(500.0, 80.0, true)); + assert_eq!(at, TileScroll::Newest, "back at the bottom: it follows the newest rows"); + let mut other = TileScroll::start(false); + assert!(!other.wheel(-10.0, 80.0, false)); + assert!(other.wheel(30.0, 80.0, false)); + assert!(other.wheel(500.0, 80.0, false)); + assert_eq!(other, TileScroll::Top(80.0), "a card with no chat does not follow its bottom"); + } + /// A dismissed card's focus passes to the card under it, or above it /// when it was the last, or to none when it was the only one. #[test] diff --git a/crates/shell/src/host_tools/scenario_tests.rs b/crates/shell/src/host_tools/scenario_tests.rs index 9e4e1125..083d6216 100644 --- a/crates/shell/src/host_tools/scenario_tests.rs +++ b/crates/shell/src/host_tools/scenario_tests.rs @@ -688,6 +688,23 @@ fn the_system_agent_and_the_person_work_with_one_app_agent_at_once() { assert_eq!((rows[audience]["lane"].clone(), rows[publish]["lane"].clone()), (json!("person"), json!("system_agent"))); assert!(task < ask && ask < audience && audience < publish, "merged by time: task {task}, ask {ask}, audience {audience}, publish {publish}"); assert!(rows.iter().all(|m| !m["content"].as_str().unwrap_or("").contains(", @@ -63,6 +76,7 @@ impl Widget for TapProbe { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { match event { Event::MouseDown(_) => self.seen += 1, + Event::MouseMove(_) => self.moves += 1, Event::TouchUpdate(update) => { self.seen += update.touches.iter().filter(|t| t.state == TouchState::Start).count(); } @@ -88,10 +102,10 @@ impl Widget for TapProbe { } return; } - if let Hit::FingerUp(e) = event.hits_with_capture_overload(cx, area, true) { - if e.is_over { - self.taps += 1; - } + match event.hits_with_capture_overload(cx, area, true) { + Hit::FingerUp(e) if e.is_over => self.taps += 1, + Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => self.hovers += 1, + _ => {} } } @@ -112,6 +126,8 @@ enum Root { Inert, /// Makepad's own GestureView. Gesture, + /// Makepad's own Button. + Button, } struct InputProbe { @@ -142,6 +158,10 @@ impl AppModule for InputProbe { let value = script_eval!(vm, { use mod.widgets.* GestureView {} }); WidgetRef::script_from_value(vm, value) } + Root::Button => { + let value = script_eval!(vm, { use mod.widgets.* Button { text: "Read" } }); + WidgetRef::script_from_value(vm, value) + } Root::Tappable | Root::Inert => { let value = script_eval!(vm, { use mod.widgets.* TapProbe {} }); let root = WidgetRef::script_from_value(vm, value); @@ -169,6 +189,8 @@ static NEWS: InputProbe = InputProbe { id: "news-probe", root: Root::Tappable }; static MAIL: InputProbe = InputProbe { id: "mail-probe", root: Root::Inert }; /// News with makepad's GestureView as its row, as its bundle has it. static GESTURE_NEWS: InputProbe = InputProbe { id: "gesture-probe", root: Root::Gesture }; +/// News with makepad's Button under the pointer, as its tab bar has them. +static BUTTON_NEWS: InputProbe = InputProbe { id: "button-probe", root: Root::Button }; fn setup() -> (Cx, ModuleHost) { let mut cx = Cx::new(Box::new(|_, _| {})); @@ -242,6 +264,18 @@ fn mouse_up(abs: Vec2d) -> Event { Event::MouseUp(MouseUpEvent { abs, button: MouseButton::PRIMARY, window_id: WindowId(0, 0), modifiers: Default::default(), time: 1.1 }) } +/// A mouse move with no button held. +fn hover(abs: Vec2d) -> Event { + Event::MouseMove(MouseMoveEvent { + abs, + lock_delta: Vec2d::default(), + window_id: WindowId(0, 0), + modifiers: Default::default(), + time: 1.2, + handled: Cell::new(Area::Empty), + }) +} + fn touch(abs: Vec2d, state: TouchState) -> Event { Event::TouchUpdate(TouchUpdateEvent { time: 1.0, @@ -296,6 +330,15 @@ fn counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { .unwrap() } +/// (hovers seen, hovers taken) of a probe app. +fn hover_counts(cx: &mut Cx, host: &mut ModuleHost, client: u64) -> (usize, usize) { + host.dispatch(cx, client, "a test read", |_, root| { + let probe = root.borrow::().unwrap(); + (probe.moves, probe.hovers) + }) + .unwrap() +} + /// News is opened, then Mail, which cascades over it. A press inside both /// lands on Mail where Mail has nothing to take it, over a News row. const NEWS_AT: (f64, f64) = (20.0, 40.0); @@ -368,6 +411,70 @@ fn a_gesture_view_behind_the_window_in_front_never_captures_its_press() { close(cx, host, vec![news, mail], frame, &[1, 2]); } +/// A hover on the front window where it takes none: Mail's tile takes it, +/// and News's app behind never sees it. +#[test] +fn a_hover_on_the_window_in_front_never_reaches_the_app_behind() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&NEWS); + let over_both = dvec2(OVER_BOTH.0, OVER_BOTH.1); + let moved = [hover(over_both)]; + assert_eq!(offer(&mut cx, &[&mail, &news], &moved), [], "a hover raises nothing"); + assert_eq!(hover_counts(&mut cx, &mut host, 2), (1, 0), "Mail's app saw it; nothing there took it"); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (0, 0), "News's app never saw it, so its row did not light up"); + // So Mail's tile took it: any window further back sees it claimed. + let Event::MouseMove(e) = &moved[0] else { unreachable!() }; + assert!(!e.handled.get().is_empty(), "the hover left Mail unclaimed"); + // Where News is in front of nothing, its row takes the hover... + offer(&mut cx, &[&mail, &news], &[hover(dvec2(NEWS_ONLY.0, NEWS_ONLY.1))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (1, 1)); + // ...and the move that takes the pointer on over Mail still reaches News, + // so the row sees its hover end. Mail took that move, so nothing lights + // up, and no move after it reaches News. + offer(&mut cx, &[&mail, &news], &[hover(over_both), hover(over_both + dvec2(10.0, 0.0))]); + assert_eq!(hover_counts(&mut cx, &mut host, 1), (2, 1), "one move to end the hover, none after it"); + // Mail's app sees every move: those over it, and the one outside it. + assert_eq!(hover_counts(&mut cx, &mut host, 2), (4, 0)); + close(cx, host, vec![news, mail], frame, &[1, 2]); +} + +/// With makepad's own Button as the app behind: a hover it takes lights it +/// up (`hover.on`) and asks for the hand cursor. A hover on Mail where Mail +/// takes none does neither. +#[test] +fn a_button_behind_the_window_in_front_never_lights_up_under_it() { + let (mut cx, mut host, news, mail, frame) = news_and_mail(&BUTTON_NEWS); + let lit = |cx: &mut Cx, host: &mut ModuleHost| { + host.dispatch(cx, 1, "a test read", |cx, root| root.borrow::