From 95bb9133591577a7f5d886c820715d62fe6a5627 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 17:55:55 +0700 Subject: [PATCH 01/13] =?UTF-8?q?feat(mobile-client,mobile):=20side=20chat?= =?UTF-8?q?s=20and=20subagents=20=E2=80=94=20core=20+=20FFI=20(android-nex?= =?UTF-8?q?t#06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-local port of upstream zeron PR #899's client half: - mobile-client: NewSession.parent_chat_id (an empty side chat: the parent's host/checkout/sandbox, a sibling of a side chat), the pending-chat overlay carries the parent (a side chat never lists on the front page), fork_side_chat (the engine's ForkSideChat RPC, a side chat's fork lists as its sibling), open_subagent (the spawn chip's doc id, read-only: no markChatSeen, no pill count; the transcript cache persists it for offline reads), SessionSnapshot:: subagents() (the spawn chips are the index), and the running- subagent count overlay feeding the rows' "● N" pill (cleared when a session leaves the registry; the derive folds working side chats in, the #899 follow-up). - mobile (FFI): SubagentState/SubagentInfo + SessionHandle.subagents() /is_subagent(), CoreClient.fork_side_chat + open_subagent, NewSession.parentChatId, SessionRow.runningSubagents, and the layout's WidgetKind::SubagentLink — a spawn card whose engine- stamped doc exists becomes a whole-card tap target. - tests: pure subagent derivation/lifecycle tests, the FFI mapping, the link widget, and real-engine side_chats_engine.rs (fork, empty create, live/finished/resumed subagent docs, running counts). --- crates/mobile-client/src/lib.rs | 2 +- crates/mobile-client/src/new_session.rs | 195 ++++- crates/mobile-client/src/session/mod.rs | 422 ++++++++++- crates/mobile-client/src/workspace/mod.rs | 20 + crates/mobile-client/src/workspace/view.rs | 43 +- .../mobile-client/tests/side_chats_engine.rs | 697 ++++++++++++++++++ crates/mobile/src/client_ffi/new_session.rs | 28 + crates/mobile/src/client_ffi/session.rs | 100 +++ crates/mobile/src/client_ffi/types.rs | 10 + crates/mobile/src/layout/display.rs | 4 + crates/mobile/src/layout/tests.rs | 81 ++ crates/mobile/src/layout/tools.rs | 51 ++ 12 files changed, 1612 insertions(+), 41 deletions(-) create mode 100644 crates/mobile-client/tests/side_chats_engine.rs diff --git a/crates/mobile-client/src/lib.rs b/crates/mobile-client/src/lib.rs index 08b0fdee65..c5d86ae300 100644 --- a/crates/mobile-client/src/lib.rs +++ b/crates/mobile-client/src/lib.rs @@ -75,7 +75,7 @@ pub use session::{ BusyPolicy, ComposerState, HostCapabilities, HostInfo, InputRequest, LiveStatus, OutgoingAttachment, PendingKind, PendingSend, QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem, RoomState, SendOutcome, SendRequest, SessionHandle, - SessionSnapshot, SnapshotWatch, UNDELIVERED_GRACE_MS, + SessionSnapshot, SnapshotWatch, SubagentRow, SubagentState, UNDELIVERED_GRACE_MS, }; // `SendState` binds once at the crate root, through workspace (07's // canonical re-export): session/pending re-exports the same type, so diff --git a/crates/mobile-client/src/new_session.rs b/crates/mobile-client/src/new_session.rs index 35a1257d32..33bde7a1eb 100644 --- a/crates/mobile-client/src/new_session.rs +++ b/crates/mobile-client/src/new_session.rs @@ -51,6 +51,11 @@ pub struct NewSession { pub cwd: Option, /// A non-blank title rides a `renameChat` after the create. pub title: Option, + /// A side chat of this chat (upstream #899): `target`, `cwd` and + /// `branch` are ignored — it runs in its parent's checkout on its + /// parent's host, and lists under the parent. A side chat's own side + /// chats hang off the same root instead (siblings). + pub parent_chat_id: Option, } /// One chat created but not yet echoed by `WatchChats` — the optimistic @@ -75,6 +80,10 @@ pub struct PendingChat { /// The chat's run configuration. pub config: Option, pub branch: Option, + /// The parent a side chat hangs off (scoped); `None` for a top-level + /// chat. The synthetic row lists under its parent, never the front + /// page. + pub parent_chat_id: Option, pub created_at: DateTime, } @@ -111,7 +120,7 @@ impl PendingChat { space_id: self.space_id.clone(), last_seen_at: None, room_gen: None, - parent_chat_id: None, + parent_chat_id: self.parent_chat_id.clone(), } } } @@ -245,6 +254,13 @@ impl Client { /// `WorktreeSpec` and the engine materializes it on run. pub async fn create_session(&self, new: NewSession) -> Result { let inner = self.inner_arc(); + // A side chat of an open chat (upstream #899): everything comes + // from the parent's row — target, cwd and branch are ignored. + if let Some(parent_id) = new.parent_chat_id.as_deref() { + return self + .create_side_chat(&inner, parent_id, new.config, new.title) + .await; + } // 1. Resolve the target: the project (its engine owns the row, the // default cwd is its folder) or an execution host. let target = match new.target { @@ -354,12 +370,181 @@ impl Client { cwd: target.cwd, config: new.config.clone(), branch: new.branch.clone().filter(|b| !b.is_empty()), + parent_chat_id: None, created_at: Utc::now(), }); } // 7. The scoped id: what every other client surface speaks. Ok(scoped_chat_id) } + + /// An empty side chat of `parent_id` (upstream #899's "New Side + /// Chat"): the parent's host, project and checkout with a fresh + /// identity, created through `Mutate createChat` with `parentChatId`. + /// A side chat of a side chat hangs off the same root — a sibling. + /// The run config is the caller's pick, but the sandbox stays the + /// parent's (the checkout is the parent's). Returns the scoped id. + async fn create_side_chat( + &self, + inner: &Arc, + parent_id: &str, + config: Option, + title: Option, + ) -> Result { + // 1. The parent's row, and the root the new chat hangs off (the + // parent, or the parent's parent when the parent is itself a + // side chat — a sibling, like the fork button). + let workspace = self.workspace(); + let Some(parent) = workspace.session(parent_id) else { + return Err(ClientError::NotFound { + message: format!("Unknown session: {parent_id}"), + }); + }; + let root = parent + .parent_chat_id + .as_deref() + .and_then(|grand| workspace.session(grand)) + .unwrap_or(parent); + // 2. The engine that owns the row — must be connected, like any + // create (nothing is queued; story 24). + let handle = self.engine_handle(&root.engine_id)?; + crate::workspace::ensure_writable(&handle)?; + // 3. Mint the id (the engine's createChat is idempotent by it). + let raw_chat_id = uuid::Uuid::new_v4().to_string(); + // 4. Create the row in the parent's checkout: `spaceId` when the + // root has a project, else the root's host device; `cwd` the + // root's checkout (its own override, else the project folder — + // the pending overlay's cwd must match what the row will say, + // so 08's first send runs in the parent's folder). + let root_space = root.project.as_ref().and_then(|project| { + let raw = ScopedId::parse(&project.id).ok()?.raw_id; + workspace + .project(&project.id) + .map(|space| (raw, space.path.clone())) + }); + let root_raw_device = ScopedId::parse(&root.device_id) + .ok() + .map(|scoped| scoped.raw_id) + .ok_or_else(|| ClientError::NotFound { + message: format!("Unknown session: {parent_id}"), + })?; + let cwd = root + .cwd + .clone() + .or_else(|| root_space.as_ref().map(|(_, path)| path.clone())) + .unwrap_or_else(|| "~".to_owned()); + // 5. The sandbox stays the parent's (the run config is the pick). + let config = config.map(|mut config| { + let parent_sandbox = inner + .workspace + .get() + .and_then(|store| store.session_config(&root.id)) + .map(|wire| wire.sandbox); + if let Some(sandbox) = parent_sandbox { + config.sandbox = sandbox; + } + config + }); + let mut params = serde_json::Map::new(); + params.insert("op".into(), "createChat".into()); + params.insert("chatId".into(), raw_chat_id.clone().into()); + params.insert("parentChatId".into(), root.id.clone().into()); + if let Some((raw_space, _)) = root_space { + params.insert("spaceId".into(), raw_space.into()); + } else { + params.insert("deviceId".into(), root_raw_device.into()); + } + if let Some(config) = &config { + let wire = serde_json::to_value(config).map_err(|error| ClientError::Internal { + message: error.to_string(), + })?; + params.insert("config".into(), wire); + } + params.insert("cwd".into(), cwd.clone().into()); + handle + .call( + roboco_rpc::methods::MUTATE, + serde_json::Value::Object(params), + ) + .await?; + // 6. The title rides a rename, as on the New Session canvas. + if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + handle + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "renameChat", + "chatId": raw_chat_id, + "title": title, + }), + ) + .await?; + } + // 7. The optimistic overlay: the row (under its parent, never the + // front page) until the engine's `WatchChats` echo replaces + // it; 08's send reads its cwd. + let engine_id = handle.engine_id().to_owned(); + let scoped_chat_id = ScopedId::encode(&EngineKey(engine_id.clone()), &raw_chat_id); + if let Some(store) = inner.workspace.get() { + store.register_pending_chat(PendingChat { + engine_id, + chat_id: scoped_chat_id.clone(), + raw_chat_id, + space_id: root.project.as_ref().map(|p| p.id.clone()), + device_id: root.device_id.clone(), + cwd, + config: config.clone(), + branch: None, + parent_chat_id: Some(root.id.clone()), + created_at: Utc::now(), + }); + } + Ok(scoped_chat_id) + } + + /// Fork `source_id` through its latest completed response into a side + /// chat (upstream #899's "Fork into Side Chat"): the engine copies + /// the history, appends the fork seam and publishes the row. A side + /// chat's fork lists as its sibling (the source's parent becomes the + /// fork's parent). Fails with the engine's reason when nothing is + /// completed yet. Returns the scoped id of the fork. + pub async fn fork_side_chat(&self, source_id: &str) -> Result { + let handle = self.resolve(source_id)?.0; + // The parent the fork hangs under: the source, or the source's + // parent when the source is a side chat (a sibling). + let workspace = self.workspace(); + let parent = workspace + .session(source_id) + .and_then(|row| row.parent_chat_id.clone()) + .filter(|parent| !parent.is_empty()) + .and_then(|parent| { + // A dangling parent (the row left) falls back to the source. + workspace.session(&parent).map(|_| parent) + }) + .unwrap_or_else(|| source_id.to_owned()); + let raw_chat_id = uuid::Uuid::new_v4().to_string(); + let reply = handle + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": raw_chat_id, + "sourceChatId": source_id, + "parentChatId": parent, + }), + ) + .await?; + let chat: roboco_proto::Chat = serde_json::from_value(reply).map_err(|error| { + ClientError::Internal { + message: format!("unexpected fork reply: {error}"), + } + })?; + // The engine published the row; this client's `WatchChats` echo + // replaces nothing here (the id is fresh) and the caller can open + // the fork at once — a scoped id naming a paired engine opens + // before the echo lands. + let scoped = ScopedId::encode(&EngineKey(handle.engine_id().to_owned()), &chat.id); + Ok(scoped) + } } #[cfg(test)] @@ -456,6 +641,7 @@ mod tests { cwd: "~".into(), config: None, branch: None, + parent_chat_id: None, created_at: DateTime::::from_timestamp_millis(10_000).unwrap(), }; assert!(pending.finished(true, 10_050)); @@ -485,6 +671,7 @@ mod tests { sandbox: roboco_proto::SandboxLevel::WorkspaceWrite, }), branch: Some("main".into()), + parent_chat_id: None, created_at: DateTime::::from_timestamp_millis(10_000).unwrap(), }; let chat = pending.synthetic_chat("engine:v1:xyz"); @@ -542,6 +729,7 @@ mod tests { branch: Some("main".into()), cwd: None, title: Some(" My chat ".into()), + parent_chat_id: None, }) .await .expect("create_session"); @@ -592,6 +780,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); @@ -658,6 +847,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect_err("phones never host sessions"); @@ -685,6 +875,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect_err("offline refuses"); @@ -721,6 +912,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); @@ -785,6 +977,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index 175d0d62bc..53476cffd0 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -25,6 +25,8 @@ use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; use roboco_doc::MessageStatus; +use roboco_doc::MessagePart; +use roboco_doc::parts::SubagentStatus; use roboco_doc::schema::SessionMessageEntry; use roboco_engine_client::registry::ScopedId; use roboco_proto::ContextUsage; @@ -40,6 +42,105 @@ pub use pending::{PendingKind, PendingSend, SendState, UNDELIVERED_GRACE_MS}; pub use queue::{QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem}; pub use transcript::apply_frame_arc; +// ── subagents (upstream #899, engine-local) ──────────────────────────────── + +/// Where a spawned subagent stands (its spawn chip's `subagent_status`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent this session's agent spawned: one linked spawn chip (the +/// chip IS the index — the engine stamps the doc id onto it; there is no +/// listing endpoint). Direct children only; nested spawns live in the +/// subagent's own transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubagentRow { + /// Its transcript doc (`{chatId}--sub--{id}`, scoped on this client) + /// — what [`Client::open_subagent`] takes. + pub doc_id: String, + /// What the spawn asked for ("Audit grapheme handling"). + pub title: String, + pub state: SubagentState, + /// The engine's one-line live tail of its output. + pub tail: Option, + /// The model the spawn picked, when it named one. + pub model: Option, + /// When the spawning reply was written. + pub spawned_at_ms: i64, +} + +/// The subagents of one transcript, in spawn order: every spawn chip the +/// engine linked to a subagent doc. `resolved` alone is NOT "done" — +/// under the eager-done policy the spawn tool's own result lands while +/// the subagent still runs; `subagent_status` is the lifecycle. Pure. +pub(crate) fn subagents_of(entries: &[Arc]) -> Vec { + let mut rows: Vec = Vec::new(); + for entry in entries { + let at = entry.created_at; + for part in &entry.parts { + let MessagePart::Tool { + call, + is_error, + resolved, + subagent_ref: Some(doc_id), + subagent_status, + subagent_tail, + .. + } = part + else { + continue; + }; + if !call.is_subagent_spawn() { + continue; + } + let state = match subagent_status { + Some(SubagentStatus::Running) => SubagentState::Running, + Some(SubagentStatus::Failed) => SubagentState::Failed, + _ if *is_error => SubagentState::Failed, + Some(SubagentStatus::Done) => SubagentState::Done, + None if *resolved => SubagentState::Done, + None => SubagentState::Running, + }; + let detail = roboco_proto::view::tool_chip_content(call).1; + // A re-spawn (a steered subagent) updates its row in place. + let title = detail.trim(); + let row = SubagentRow { + doc_id: doc_id.clone(), + title: if title.is_empty() { + "Subagent".to_owned() + } else { + title.to_owned() + }, + state, + tail: subagent_tail + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned), + model: call.subagent_model().map(str::to_owned), + spawned_at_ms: at, + }; + match rows.iter_mut().find(|r| r.doc_id == row.doc_id) { + Some(existing) => *existing = row, + None => rows.push(row), + } + } + } + rows +} + +/// Subagents whose spawn chips still run — the sessions-list "● N" pill +/// (plus the working side chats the view layers on top). Pure. +fn running_subagent_count(entries: &[Arc]) -> u32 { + subagents_of(entries) + .into_iter() + .filter(|row| row.state == SubagentState::Running) + .count() as u32 +} + use crate::cache::EngineCacheHandle; use crate::{Client, ClientInner}; @@ -81,6 +182,13 @@ impl SessionSnapshot { pub fn host_entries(&self) -> &[Arc] { &self.entries } + + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). Empty while the transcript has no linked spawn + /// chips. + pub fn subagents(&self) -> Vec { + subagents_of(&self.entries) + } } // ── mutable state ────────────────────────────────────────────────────────── @@ -123,6 +231,10 @@ pub(crate) struct SessionCore { pub(crate) chat_id: String, /// The engine's raw chat id — what the wire sees. pub(crate) raw_chat_id: String, + /// A subagent transcript (`{chatId}--sub--{id}`): read-only — no chat + /// row exists, so nothing marks it seen and no send is expected (the + /// UI shows no composer; upstream #899's viewer contract). + pub(crate) read_only: bool, pub(crate) client: Weak, pub(crate) cache: EngineCacheHandle, pub(crate) state: Mutex, @@ -145,6 +257,7 @@ impl SessionCore { client: Weak, cache: EngineCacheHandle, tasks: CancellationToken, + read_only: bool, ) -> Arc { // Seed from the offline cache: the chat paints before any frame. let seed = cache.load_transcript_state(raw_chat_id); @@ -182,6 +295,7 @@ impl SessionCore { engine_id: engine_id.to_owned(), chat_id: chat_id.to_owned(), raw_chat_id: raw_chat_id.to_owned(), + read_only, client, cache, state: Mutex::new(state), @@ -503,7 +617,7 @@ impl SessionCore { /// Publish the snapshot at `revision` and emit `SessionChanged`. pub(crate) fn publish_transcript(&self, revision: u64) { let inputs = self.row_inputs(); - let snapshot = { + let (snapshot, running_subagents) = { let state = lock(&self.state); let session = inputs.session.as_ref(); let streaming = state @@ -514,7 +628,8 @@ impl SessionCore { roboco_proto::view::effective_indicator(Some(session), chrono::Utc::now()) == roboco_proto::view::Indicator::Working }); - SessionSnapshot { + let running = running_subagent_count(&state.entries); + let snapshot = SessionSnapshot { chat_id: self.chat_id.clone(), revision, entries: state.entries.clone(), @@ -530,14 +645,39 @@ impl SessionCore { hydrated: state.hydrated, history_pending: state.history_pending, context_usage: state.context_usage, - } + }; + (snapshot, running) }; self.snapshot_tx.send_replace(Arc::new(snapshot)); + // The "● N" pill: the running-subagent count rides the same + // publication as the rows (a chip flipping to Done is a transcript + // change). Written after the state lock is gone — the store is + // last in the crate's lock order. + self.sync_running_subagents(running_subagents); if let Some(inner) = self.client.upgrade() { inner.pump.session(&self.chat_id, revision); } } + /// Push the running-subagent count to 07's workspace store (the + /// sessions-list "● N" pill). Read-only sessions never write: nested + /// spawns live in the subagent's own doc, which has no sidebar row. + pub(crate) fn sync_running_subagents(&self, running: u32) { + if self.read_only { + return; + } + if let Some(store) = self.workspace_store() { + store.set_running_subagents(&self.chat_id, running); + } + } + + /// The session left the registry (evicted or backgrounded): its pill + /// count is no longer maintained — clear it, so a stale "● N" never + /// outlives the subscription that proved it. + pub(crate) fn clear_running_subagents(&self) { + self.sync_running_subagents(0); + } + // ── echo maintenance ───────────────────────────────────────────────── pub(crate) fn remove_echo(&self, message_id: &str) { @@ -588,6 +728,11 @@ impl SessionCore { } pub(crate) fn mark_seen(&self) { + // A subagent transcript has no chat row; marking it seen would + // fire a `markChatSeen` for a doc id the engine does not know. + if self.read_only { + return; + } if let Some(inner) = self.client.upgrade() { // 07's mark_seen: the monotonic local stamp (never rolled // back) plus the fire-and-forget `Mutate markChatSeen`. @@ -786,6 +931,30 @@ impl SessionRegistry { engine_id: &str, chat_id: &str, raw_chat_id: &str, + ) -> Arc { + self.open_inner(inner, engine_id, chat_id, raw_chat_id, false) + } + + /// A subagent's transcript doc, read-only (upstream #899's viewer): + /// same seeding and subscription, but [`SessionCore::read_only`] — no + /// mark-seen, no pill count — and the doc id is the registry key. + pub(crate) fn open_read_only( + &self, + inner: &Arc, + engine_id: &str, + doc_id: &str, + raw_doc_id: &str, + ) -> Arc { + self.open_inner(inner, engine_id, doc_id, raw_doc_id, true) + } + + fn open_inner( + &self, + inner: &Arc, + engine_id: &str, + chat_id: &str, + raw_chat_id: &str, + read_only: bool, ) -> Arc { { let registry = lock(&self.inner); @@ -809,6 +978,7 @@ impl SessionRegistry { Arc::downgrade(inner), cache, tasks, + read_only, ); { let mut registry = lock(&self.inner); @@ -823,48 +993,61 @@ impl SessionRegistry { /// A session is never evicted while view-attached, while its tail is /// streaming, or while it has pending sends. pub(crate) fn evict(&self) { - let mut registry = lock(&self.inner); - let mut idle: Vec<(i64, String)> = registry - .sessions - .values() - .filter(|core| { - !core.view_attached() - && !core.has_pending_sends() - && !core.snapshot().streaming - }) - .map(|core| (core.touched_ms(), core.chat_id.clone())) - .collect(); - if idle.len() <= WARM_SESSION_CAP { - return; - } - idle.sort(); - let excess = idle.len() - WARM_SESSION_CAP; - for (_, chat_id) in idle.into_iter().take(excess) { - tracing::debug!(chat = %chat_id, "evicting warm session"); - if let Some(core) = registry.sessions.remove(&chat_id) { - core.cancel(); + let evicted: Vec> = { + let mut registry = lock(&self.inner); + let mut idle: Vec<(i64, String)> = registry + .sessions + .values() + .filter(|core| { + !core.view_attached() + && !core.has_pending_sends() + && !core.snapshot().streaming + }) + .map(|core| (core.touched_ms(), core.chat_id.clone())) + .collect(); + if idle.len() <= WARM_SESSION_CAP { + return; } + idle.sort(); + let excess = idle.len() - WARM_SESSION_CAP; + idle.into_iter() + .take(excess) + .filter_map(|(_, chat_id)| registry.sessions.remove(&chat_id)) + .collect() + }; + // Off the registry lock (the store is last in the lock order): + // the pill count a dropped session proved is cleared with it. + for core in evicted { + tracing::debug!(chat = %core.chat_id, "evicting warm session"); + core.cancel(); + core.clear_running_subagents(); } } /// `on_background`: close the subscriptions of detached sessions that /// are not streaming and have no pending sends. pub(crate) fn close_background(&self) { - let mut registry = lock(&self.inner); - let quiet: Vec = registry - .sessions - .values() - .filter(|core| { - !core.view_attached() - && !core.has_pending_sends() - && !core.snapshot().streaming - }) - .map(|core| core.chat_id.clone()) - .collect(); - for chat_id in quiet { - if let Some(core) = registry.sessions.remove(&chat_id) { - core.cancel(); - } + let dropped: Vec> = { + let mut registry = lock(&self.inner); + let quiet: Vec> = registry + .sessions + .values() + .filter(|core| { + !core.view_attached() + && !core.has_pending_sends() + && !core.snapshot().streaming + }) + .cloned() + .collect(); + quiet + .into_iter() + .filter_map(|core| registry.sessions.remove(&core.chat_id)) + .collect() + }; + // Off the registry lock: the pill counts go with the sessions. + for core in dropped { + core.cancel(); + core.clear_running_subagents(); } } @@ -1154,6 +1337,30 @@ impl Client { Some(SessionHandle { core }) } + /// Open a subagent's transcript read-only — the doc a spawn chip's + /// `subagent_ref` names (`{chatId}--sub--{suffix}`, scoped on this + /// client). The engine serves the doc like any chat doc, so a running + /// subagent streams live, a finished one reads its persisted copy, and + /// one that resumes streams its new output (upstream #899's fix, by + /// construction: there is no frozen copy to get stuck on). The doc + /// has no chat row, so the session is read-only — no `markChatSeen`, + /// no sends — and the transcript cache still persists it, so a + /// subagent already read shows offline after a restart. + pub fn open_subagent(&self, doc_id: &str) -> Result { + let (handle, raw_doc_id) = self.resolve(doc_id)?; + if !raw_doc_id.contains("--sub--") { + return Err(ClientError::InvalidArgument { + message: format!("not a subagent doc: {doc_id}"), + }); + } + let engine_id = handle.engine_id().to_owned(); + let core = self + .inner + .sessions + .open_read_only(&self.inner, &engine_id, doc_id, &raw_doc_id); + Ok(SessionHandle { core }) + } + /// The view closed; the session stays warm until evicted. pub fn close_session(&self, chat_id: &str) { if let Some(handle) = self.session(chat_id) { @@ -1373,6 +1580,7 @@ pub(crate) mod testkit { std::sync::Weak::new(), cache_handle(), tokio_util::sync::CancellationToken::new(), + false, ); Arc::try_unwrap(core).unwrap_or_else(|_| unreachable!("the only reference")) } @@ -1389,6 +1597,144 @@ pub(crate) mod testkit { mod tests { use super::*; + /// One assistant entry carrying the given tool parts. + fn entry(parts: Vec) -> Arc { + Arc::new(SessionMessageEntry { + id: "e1".into(), + role: roboco_doc::MessageRole::Assistant, + parts, + created_at: 1_000, + device_id: "dev".into(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + } + + /// One spawn chip (an `Agent` tool call) with the subagent stamps. + fn spawn( + doc_id: &str, + status: Option, + resolved: bool, + is_error: bool, + ) -> roboco_doc::MessagePart { + roboco_doc::MessagePart::Tool { + id: format!("p-{doc_id}"), + call: roboco_proto::ToolCall::Unknown { + name: "Agent: Audit grapheme handling".into(), + input: Some(serde_json::json!({ "model": "haiku" })), + }, + is_error, + resolved, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some(doc_id.into()), + subagent_status: status, + subagent_tail: Some(" Reading RowView.swift… ".into()), + } + } + + /// `subagents_of`: one row per linked spawn chip, in spawn order, + /// with the chip's lifecycle (never `resolved` alone), the model the + /// spawn named, the trimmed tail, and the description as the title. + #[test] + fn subagents_read_the_spawn_chips() { + use roboco_doc::parts::SubagentStatus as S; + let entries = vec![entry(vec![ + spawn("c--sub--a", Some(S::Running), true, false), + spawn("c--sub--b", None, false, false), + // Not a spawn: an ordinary tool with a stray ref must not + // surface (the genus gate). + roboco_doc::MessagePart::Tool { + id: "p-x".into(), + call: roboco_proto::ToolCall::Exec { command: "ls".into() }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some("c--sub--stray".into()), + subagent_status: None, + subagent_tail: None, + }, + ])]; + let rows = subagents_of(&entries); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].doc_id, "c--sub--a"); + assert_eq!(rows[0].title, "Audit grapheme handling"); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].model.as_deref(), Some("haiku")); + assert_eq!(rows[0].tail.as_deref(), Some("Reading RowView.swift…")); + assert_eq!(rows[0].spawned_at_ms, 1_000); + // No status, unresolved: the chip is still live (running). + assert_eq!(rows[1].state, SubagentState::Running); + } + + /// The lifecycle table (upstream #899's `subagent_status` rules): + /// `resolved` alone is NOT done (eager-done); an error fails; a + /// resolved-with-no-status chip is done. + #[test] + fn subagent_states_follow_the_chip_lifecycle() { + use roboco_doc::parts::SubagentStatus as S; + let of = |status: Option, resolved: bool, is_error: bool| { + subagents_of(&[entry(vec![spawn("c--sub--s", status, resolved, is_error)])]) + .pop() + .unwrap() + .state + }; + assert_eq!(of(Some(S::Running), true, false), SubagentState::Running); + assert_eq!(of(Some(S::Done), true, false), SubagentState::Done); + assert_eq!(of(Some(S::Failed), false, false), SubagentState::Failed); + assert_eq!(of(None, false, false), SubagentState::Running); + assert_eq!(of(None, true, false), SubagentState::Done); + assert_eq!(of(None, false, true), SubagentState::Failed); + assert_eq!( + of(None, true, true), + SubagentState::Failed, + "an error outranks a resolution" + ); + assert_eq!( + of(Some(S::Done), true, true), + SubagentState::Failed, + "an error outranks a done stamp" + ); + } + + /// A re-spawn (the same doc id, a steered subagent) updates its row + /// in place instead of listing twice. + #[test] + fn a_respawned_subagent_updates_its_row() { + use roboco_doc::parts::SubagentStatus as S; + let first = entry(vec![spawn("c--sub--s", Some(S::Done), true, false)]); + let mut second = (*first).clone(); + second.created_at = 2_000; + let rows = subagents_of(&[first, Arc::new(second)]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].doc_id, "c--sub--s"); + assert_eq!(rows[0].spawned_at_ms, 2_000, "the newest spawn wins"); + } + + /// The pill counts only running subagents. + #[test] + fn running_subagent_count_counts_only_the_running() { + use roboco_doc::parts::SubagentStatus as S; + let entries = vec![entry(vec![ + spawn("c--sub--a", Some(S::Running), true, false), + spawn("c--sub--b", Some(S::Done), true, false), + spawn("c--sub--c", Some(S::Failed), false, false), + spawn("c--sub--d", None, false, false), + ])]; + assert_eq!(running_subagent_count(&entries), 2); + assert_eq!(running_subagent_count(&[]), 0); + } + #[test] fn the_warm_cap_and_preload_cap_are_the_tickets_values() { assert_eq!(WARM_SESSION_CAP, 6); diff --git a/crates/mobile-client/src/workspace/mod.rs b/crates/mobile-client/src/workspace/mod.rs index bfbff1834f..468e50ea09 100644 --- a/crates/mobile-client/src/workspace/mod.rs +++ b/crates/mobile-client/src/workspace/mod.rs @@ -72,6 +72,10 @@ pub(crate) struct WorkspaceStore { /// source for the derive (the optimistic row before `WatchChats` /// echoes the id) and a `cwd`/`config` source for 08's send. pending_chats: Arc>>, + /// The running-subagent count per scoped chat id (upstream #899's + /// "● N" pill): the open sessions write it from their transcripts, + /// the rows read it. Sessions that leave the registry clear theirs. + running_subagents: Arc>>, } /// The mutable, lock-guarded half of the store. @@ -105,6 +109,7 @@ impl WorkspaceStore { pr: change_requests::ChangeRequestWatches::new(weak.clone(), notify), send_states: Arc::new(Mutex::new(HashMap::new())), pending_chats: Arc::new(Mutex::new(HashMap::new())), + running_subagents: Arc::new(Mutex::new(HashMap::new())), }); // Seed with the derive of an empty fleet: a client with no engines // stays silent (its first recompute reproduces this content hash), @@ -116,6 +121,7 @@ impl WorkspaceStore { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }); *store @@ -175,6 +181,7 @@ impl WorkspaceStore { .collect(); let overlays = state.overlays.clone(); let send_states = lock(&self.send_states).clone(); + let running_subagents = lock(&self.running_subagents).clone(); // Ticket 09: drop pending chats the engine has echoed (or whose // 30 s TTL ran out); the survivors are the derive's extra rows. let pending_chats = { @@ -204,6 +211,7 @@ impl WorkspaceStore { change_requests: &statuses, send_states: &send_states, pending_chats: &pending_chats, + running_subagents: &running_subagents, previous: Some(&previous), }); if next.content_hash() == previous.content_hash() { @@ -286,6 +294,18 @@ impl WorkspaceStore { } } + /// The running-subagent count of a chat (upstream #899's "● N" pill + /// on session rows). The open session writes it on every transcript + /// publication; `0` clears it. Recomputes on every change — a count + /// that did not move changes nothing (the row hash holds it). + pub(crate) fn set_running_subagents(&self, chat_id: &str, count: u32) { + let mut counts = lock(&self.running_subagents); + if counts.insert(chat_id.to_owned(), count) != Some(count) { + drop(counts); + self.notify.notify_one(); + } + } + // ── ticket 09's pending chats ────────────────────────────────────────── /// Register the optimistic pending chat of a just-created session diff --git a/crates/mobile-client/src/workspace/view.rs b/crates/mobile-client/src/workspace/view.rs index 2962a19423..91e6351b52 100644 --- a/crates/mobile-client/src/workspace/view.rs +++ b/crates/mobile-client/src/workspace/view.rs @@ -115,6 +115,11 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option, + /// Agents running under this chat right now — its open transcript's + /// running subagents plus its working side chats (upstream #899's + /// "● N" pill; the follow-up folds the side chats in). Counts only + /// while a session is open (the chip state lives in the transcript). + pub running_subagents: u32, /// `last_message_at`, falling back to `created_at` (the sort key). pub last_activity_ms: i64, /// [`relative_time_label`] of `last_activity_ms` at derive time (the 1 Hz @@ -468,6 +473,10 @@ pub struct DeriveContext<'a> { /// renders them as synthetic rows so the Sessions list shows a new /// chat at once. An engine row with the same id wins. pub pending_chats: &'a HashMap, + /// Running subagents per scoped chat id (upstream #899): the open + /// sessions' spawn-chip state, written by the session tasks. The + /// derive folds each row's working side chats on top. + pub running_subagents: &'a HashMap, pub previous: Option<&'a WorkspaceSnapshot>, } @@ -512,6 +521,7 @@ fn hash_row(row: &SessionRow) -> u64 { attention_rank(row.indicator).hash(&mut h); attention_rank(row.host_indicator).hash(&mut h); row.working_since_ms.hash(&mut h); + row.running_subagents.hash(&mut h); row.last_activity_ms.hash(&mut h); row.time_label.hash(&mut h); row.created_at_ms.hash(&mut h); @@ -599,6 +609,9 @@ struct RowContext<'a> { sessions: HashMap<&'a str, &'a Session>, pinned: &'a HashSet, section_of: &'a HashMap, + /// Working (unarchived) side chats per parent (scoped) — the #899 + /// follow-up's half of the "● N" pill. + working_children: &'a HashMap, } fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc { @@ -616,6 +629,10 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc None, }; + // The "● N" pill (upstream #899): the open transcript's running + // subagents plus the working side chats folded in by the derive. + let running_subagents = cx.running_subagents.get(&chat.id).copied().unwrap_or(0) + + rc.working_children.get(chat.id.as_str()).copied().unwrap_or(0); let project = chat .space_id .as_deref() @@ -678,6 +695,7 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc) -> WorkspaceSnapshot { } } - // 3. Rows, one context per engine. + // 3. Rows, one context per engine. First the working-children pre-pass + // (the #899 follow-up): a parent's working side chats count into its + // "● N" pill, so the map is built before any row. + let working_children: HashMap = merged + .chats + .values() + .filter(|chat| chat.parent_chat_id.is_some() && !chat.archived) + .filter_map(|chat| { + let parent = chat.parent_chat_id.as_deref()?; + let session = sessions.get(chat.id.as_str()).copied(); + (display_status(chat, session, cx.now) == ChatIndicator::Working) + .then(|| parent.to_owned()) + }) + .fold(HashMap::new(), |mut acc, parent| { + *acc.entry(parent).or_insert(0) += 1; + acc + }); let mut rows: HashMap> = HashMap::new(); // Pending chats grouped by their engine — they render through their // engine's own row context (device names, engine online state). @@ -809,6 +843,7 @@ pub fn derive(cx: &DeriveContext<'_>) -> WorkspaceSnapshot { sessions: sessions.clone(), pinned: &pinned, section_of: §ion_of, + working_children: &working_children, }; for raw_chat in &engine.rows.chats { let scoped = ScopedId::encode(&key, &raw_chat.id); @@ -1308,6 +1343,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }) } @@ -1590,6 +1626,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &send_states, pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }); snapshot @@ -1975,6 +2012,7 @@ mod tests { cwd: "/repo".into(), config: None, branch: Some("main".into()), + parent_chat_id: None, created_at: at(NOW_SECS - 5), } } @@ -1996,6 +2034,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); let row = snapshot @@ -2033,6 +2072,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); // One row, and it is the engine's (titled), not the synthetic one. @@ -2055,6 +2095,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); assert!(snapshot.sessions.is_empty()); diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs new file mode 100644 index 0000000000..38a651e713 --- /dev/null +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -0,0 +1,697 @@ +//! Real-engine side-chat and subagent tests (android-next #06, the port +//! of upstream PR #899): `fork_side_chat`, the empty side-chat create, +//! the subagent viewer's read-only docs (live while running, persisted +//! once finished, live again on a later write), and the "● N" running +//! counts on the workspace rows. Every frame comes from an in-process +//! `EngineCore` served over loopback — no protocol mocks. + +mod support; + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_engine::{EngineCore, HarnessRegistry, HarnessId}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_mobile_client::session::{SendRequest, SubagentState}; +use roboco_mobile_client::{Client, NewSession, SessionTarget}; +use roboco_proto::{ + AgentEvent, ChatConfig, DoneStatus, HarnessId as ProtoHarnessId, Model, ReasoningLevel, + RunRequest, SandboxLevel, SteeringMode, ToolCall, +}; + +use support::{serve, test_client, wait_for_fleet, wait_for_view}; + +// ── the harness ──────────────────────────────────────────────────────────── + +/// What the harness's one reply does. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Reply { + /// A plain one-line turn (nothing nested). + Plain, + /// Spawn a subagent that streams one line and finishes inside the turn. + SpawnFinish, + /// Spawn a subagent and keep the turn (and it) running: the stream + /// stays open after the nested text. + SpawnLive, +} + +/// A mock harness that records every `RunRequest` and answers with the +/// scenario's event stream. `SpawnLive` parks the stream (the turn never +/// completes), so the spawned subagent stays `Running`. +struct ScenarioHarness { + reply: Reply, + requests: Arc>>, +} + +#[async_trait] +impl Harness for ScenarioHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Scenario" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::StepBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![Model { + id: "mock-1".into(), + label: "Mock 1".into(), + description: None, + reasoning_levels: vec![ReasoningLevel::Medium], + options: vec![], + }]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + self.requests.lock().unwrap().push(request); + let reply = self.reply; + let events: Vec> = match reply { + Reply::Plain => vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + }), + Ok(AgentEvent::TextDelta { + text: format!("ack: {}", request.prompt), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + ], + Reply::SpawnFinish | Reply::SpawnLive => { + let sub_done = reply == Reply::SpawnFinish; + let mut events = vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + }), + // The spawn call (the `Agent[: desc]` convention the + // genus gate keys on). + Ok(AgentEvent::ToolCall { + id: "tool-1".into(), + call: ToolCall::Unknown { + name: "Agent: Audit the repo".into(), + input: Some(serde_json::json!({ "prompt": "audit", "model": "mock-1" })), + }, + }), + // Nested traffic: routed to the subagent's own doc, + // never folded into the parent. + Ok(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::TextDelta { + text: "subagent: reading the tree".into(), + }), + }), + Ok(AgentEvent::ToolResult { + id: "tool-1".into(), + is_error: false, + output: Some("spawned".into()), + diff: None, + }), + ]; + if sub_done { + events.push(Ok(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + })); + } + events.push(Ok(AgentEvent::TextDelta { + text: "parent done".into(), + })); + if sub_done { + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + })); + } + events + } + }; + if reply == Reply::SpawnLive { + // Park the turn: everything above is delivered, then the + // stream never ends — the subagent keeps running. + return Ok(futures::stream::select( + futures::stream::iter(events), + futures::stream::pending(), + ) + .boxed()); + } + Ok(futures::stream::iter(events).boxed()) + } +} + +// ── the rig ──────────────────────────────────────────────────────────────── + +struct Rig { + client: Arc, + core: EngineCore, + engine_id: String, + #[allow(dead_code)] + engine_dir: tempfile::TempDir, + #[allow(dead_code)] + data_dir: tempfile::TempDir, + requests: Arc>>, +} + +impl Rig { + fn scope(&self, raw: &str) -> String { + self.client.scope(&self.engine_id, raw) + } + + /// The parent chat this rig works on (its own checkout). + fn parent_id(&self) -> String { + self.scope("parent") + } +} + +/// Assemble the engine with the scenario harness, a parent chat with a +/// known checkout, serve it, and pair a client. +async fn rig(reply: Reply) -> Rig { + let engine_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let registry = HarnessRegistry::new(); + let requests = Arc::new(Mutex::new(Vec::new())); + registry.register(Arc::new(ScenarioHarness { reply, requests: requests.clone() })); + let core = EngineCore::assemble_with_profile( + roboco_engine::EngineProfile::local(engine_dir.path()).unwrap(), + Arc::new(registry), + HarnessId::Mock, + ) + .unwrap(); + core.workspace + .create_chat_with_parent( + "parent", + None, + Some(&core.device_id), + Some(ChatConfig { + harness: ProtoHarnessId::Mock, + model: Some("mock-1".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + }), + Some("/checkout/parent".into()), + None, + ) + .unwrap(); + let listener = serve(&core, engine_dir.path()).await; + let url = support::pairing_url(&listener, engine_dir.path(), None); + let client = test_client(data_dir.path(), Vec::new(), Arc::new(support::Recorder::default())); + let record = tokio::time::timeout(Duration::from_secs(10), client.pair_engine(&url, None)) + .await + .expect("pairing completes") + .expect("pairing succeeds"); + wait_for_view(&client, &record.engine_id, "connected", |view| { + view.state == roboco_mobile_client::EngineState::Connected + }) + .await; + wait_for_fleet(&client, "parent row", |fleet| { + fleet + .engine(&record.engine_id) + .is_some_and(|engine| engine.rows.chats.iter().any(|chat| chat.id == "parent")) + }) + .await; + Rig { + client, + core, + engine_id: record.engine_id, + engine_dir, + data_dir, + requests, + } +} + +impl Rig { + /// Send one message on the parent chat and wait for the mock reply's + /// first text to land (the plain reply's `ack:` line). + async fn ask(&self, text: &str) { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let outcome = handle + .send(SendRequest::text(text)) + .await + .expect("send"); + assert!(matches!(outcome, roboco_mobile_client::SendOutcome::Started { .. })); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let answered = handle + .snapshot() + .entries + .iter() + .any(|entry| entry.role == roboco_doc::MessageRole::Assistant); + if answered { + return; + } + assert!(Instant::now() < deadline, "the reply never arrived"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// The engine's own row for a raw chat id. + fn engine_row(&self, raw: &str) -> roboco_proto::Chat { + self.core + .workspace + .chat(raw) + .unwrap() + .unwrap_or_else(|| panic!("engine row for {raw}")) + } + + /// The subagent doc id the parent's chips carry (scoped). + fn subagent_doc(&self) -> String { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let rows = handle.subagents(); + assert_eq!(rows.len(), 1, "one subagent: {rows:?}"); + rows[0].doc_id.clone() + } +} + +// ── fork ─────────────────────────────────────────────────────────────────── + +/// Forking copies the history through the last completed reply and lists +/// the copy under its parent; forking before anything completed fails +/// with the engine's reason; a side chat's fork lists as a sibling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_side_chat_copies_history_under_its_parent() { + let rig = rig(Reply::Plain).await; + let parent = rig.parent_id(); + + // Nothing completed yet: the engine refuses with its reason. + let error = rig + .client + .fork_side_chat(&parent) + .await + .expect_err("fork needs a completed reply"); + assert!( + matches!(&error, roboco_mobile_client::ClientError::HostError { message } if message.contains("completed")), + "the engine's reason carries over: {error:?}" + ); + + // A completed turn, then the fork. + rig.ask("hello").await; + let fork = rig.client.fork_side_chat(&parent).await.expect("fork"); + assert!(roboco_engine_client::registry::ScopedId::is_scoped(&fork)); + let raw_fork = roboco_engine_client::registry::ScopedId::parse(&fork) + .expect("scoped fork id") + .raw_id; + // The row: a child of the parent, in its checkout, not archived. + let row = rig.engine_row(&raw_fork); + assert_eq!(row.parent_chat_id.as_deref(), Some("parent")); + assert_eq!(row.cwd.as_deref(), Some("/checkout/parent")); + assert!(!row.archived); + // The history: the parent's entries plus the fork seam. + let entries = rig + .core + .doc_host + .open(&raw_fork) + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert!( + entries + .iter() + .any(|entry| entry.role == roboco_doc::MessageRole::User), + "the source's history was copied" + ); + assert!( + entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Fork { .. }))), + "the fork seam sits between the copy and the fork's own turns" + ); + + // The fork opens at once (before the WatchChats echo matters) and + // lists in the workspace children. + let handle = rig.client.open_session(&fork).expect("open the fork"); + assert!(!handle.is_subagent()); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let listed = rig + .client + .workspace() + .children(&parent) + .iter() + .any(|row| row.id == fork); + if listed { + break; + } + assert!(Instant::now() < deadline, "the fork never listed as a child"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A side chat's fork lists as its sibling: the fork's parent is the + // same root, not the fork itself. + let sibling = rig.client.fork_side_chat(&fork).await.expect("sibling fork"); + let raw_sibling = roboco_engine_client::registry::ScopedId::parse(&sibling) + .expect("scoped id") + .raw_id; + assert_eq!( + rig.engine_row(&raw_sibling).parent_chat_id.as_deref(), + Some("parent"), + "the sibling hangs off the fork's parent" + ); +} + +// ── the empty side chat ──────────────────────────────────────────────────── + +/// Creating a side chat takes the parent's host, checkout and sandbox, +/// lists under the parent (never the front page), and its first send +/// runs in the parent's folder. A side chat's own side chat is a sibling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_side_chat_runs_in_the_parents_checkout() { + let rig = rig(Reply::Plain).await; + let parent = rig.parent_id(); + let config = ChatConfig { + harness: "mock".into(), + model: Some("mock-1".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::ReadOnly, + }; + + let side = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: Some(config), + branch: Some("ignored".into()), + cwd: Some("/ignored".into()), + title: None, + parent_chat_id: Some(parent.clone()), + }) + .await + .expect("side chat"); + assert!(roboco_engine_client::registry::ScopedId::is_scoped(&side)); + let raw_side = roboco_engine_client::registry::ScopedId::parse(&side) + .expect("scoped id") + .raw_id; + // The engine row: the parent's checkout and host, the parent's + // sandbox (not the draft's read-only), the draft's model. + let row = rig.engine_row(&raw_side); + assert_eq!(row.parent_chat_id.as_deref(), Some("parent")); + assert_eq!(row.cwd.as_deref(), Some("/checkout/parent")); + assert_eq!(row.device_id, rig.core.device_id); + let row_config = row.config.expect("the row carries the config"); + assert_eq!(row_config.sandbox, SandboxLevel::WorkspaceWrite, "the parent's sandbox"); + assert_eq!(row_config.harness, ProtoHarnessId::Mock); + assert_eq!(row_config.model.as_deref(), Some("mock-1")); + + // The first send runs in the parent's checkout. + let handle = rig.client.open_session(&side).unwrap(); + handle + .send(SendRequest::text("side hello")) + .await + .expect("send"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let ran_in_parent_folder = rig + .requests + .lock() + .unwrap() + .iter() + .any(|request| request.prompt.contains("side hello") + && request.cwd == "/checkout/parent"); + if ran_in_parent_folder { + break; + } + assert!( + Instant::now() < deadline, + "the side chat's first send never ran in the parent's checkout" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // The row lists under the parent (the pending overlay first, then the + // engine's echo) and never in the front page. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let child = rig + .client + .workspace() + .children(&parent) + .iter() + .any(|row| row.id == side); + let front = rig + .client + .workspace() + .front + .recent + .iter() + .any(|row| row.id == side); + if child && !front { + break; + } + assert!( + Instant::now() < deadline, + "the side chat never listed under its parent (and off the front page)" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A side chat of the side chat is a sibling: the same root parent. + let sibling = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some(side.clone()), + }) + .await + .expect("sibling"); + let raw_sibling = roboco_engine_client::registry::ScopedId::parse(&sibling) + .expect("scoped id") + .raw_id; + assert_eq!( + rig.engine_row(&raw_sibling).parent_chat_id.as_deref(), + Some("parent"), + "a side chat's side chat hangs off the same root" + ); + + // An unknown parent is a NotFound, before any engine call. + let error = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some("engine:v1:no-such-chat".into()), + }) + .await + .expect_err("unknown parent"); + assert!(matches!(error, roboco_mobile_client::ClientError::NotFound { .. }), "{error:?}"); +} + +// ── the subagent viewer ──────────────────────────────────────────────────── + +/// A spawned subagent lists on the open session, its doc opens read-only +/// and streams, and the parent's row carries the "● N" count while it +/// runs — cleared when the chip settles. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_subagent_doc_streams_and_counts_while_it_runs() { + let rig = rig(Reply::SpawnLive).await; + let parent = rig.parent_id(); + + // The turn spawns a subagent and parks: the chip stays running. + rig.ask("spawn one").await; + let deadline = Instant::now() + Duration::from_secs(20); + let rows = loop { + let handle = rig.client.open_session(&parent).unwrap(); + let rows = handle.subagents(); + if !rows.is_empty() { + break rows; + } + assert!(Instant::now() < deadline, "the subagent never spawned"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].title, "Audit the repo"); + assert_eq!(rows[0].model.as_deref(), Some("mock-1")); + assert_eq!(rows[0].tail.as_deref(), Some("subagent: reading the tree")); + assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); + + // The parent's row counts it (the "● N" pill). + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 1 { + break; + } + assert!(Instant::now() < deadline, "the running count never landed: {count}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // The doc opens read-only and streams its transcript. + let doc = rig.subagent_doc(); + let viewer = rig.client.open_subagent(&doc).expect("open subagent"); + assert!(viewer.is_subagent()); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let text = viewer.snapshot(); + if text + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("reading the tree")))) + { + break; + } + assert!(Instant::now() < deadline, "the subagent transcript never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A doc id that is not a subagent doc is refused. + let error = rig + .client + .open_subagent(&parent) + .expect_err("a chat id is not a subagent doc"); + assert!( + matches!(error, roboco_mobile_client::ClientError::InvalidArgument { .. }), + "{error:?}" + ); + + // A later write to the doc (a resumed subagent) streams into the SAME + // handle — upstream #899's fix, by construction: there is no frozen + // copy to get stuck on. + let raw_doc = roboco_engine_client::registry::ScopedId::parse(&doc) + .expect("scoped doc id") + .raw_id; + rig.core + .doc_host + .open(&raw_doc) + .unwrap() + .doc() + .push_message(&roboco_doc::schema::SessionMessageEntry { + duration_ms: None, + id: "resumed".into(), + role: roboco_doc::MessageRole::Assistant, + parts: vec![roboco_doc::MessagePart::Text { + id: "p1".into(), + text: "resumed output".into(), + }], + created_at: 2_000, + device_id: rig.core.device_id.clone(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + }) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let resumed = viewer.snapshot().entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("resumed output")))); + if resumed { + break; + } + assert!(Instant::now() < deadline, "the resumed subagent never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// A subagent that finishes settles its chip and clears the count; its +/// transcript still reads (persisted on the engine), and the session's +/// subagent list marks it done. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_finished_subagent_clears_the_count_and_still_reads() { + let rig = rig(Reply::SpawnFinish).await; + let parent = rig.parent_id(); + + rig.ask("spawn and finish").await; + // The chip settles to Done. + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let handle = rig.client.open_session(&parent).unwrap(); + let rows = handle.subagents(); + if rows.len() == 1 && rows[0].state == SubagentState::Done { + break; + } + assert!(Instant::now() < deadline, "the subagent never settled: {rows:?}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The count cleared. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 0 { + break; + } + assert!(Instant::now() < deadline, "the count never cleared: {count}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The transcript still reads from the engine. + let doc = rig.subagent_doc(); + let viewer = rig.client.open_subagent(&doc).expect("open the finished subagent"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let text = viewer.snapshot(); + if text + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("reading the tree")))) + { + break; + } + assert!(Instant::now() < deadline, "the finished transcript never read"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} diff --git a/crates/mobile/src/client_ffi/new_session.rs b/crates/mobile/src/client_ffi/new_session.rs index f8230cf20a..5e9cf6e4af 100644 --- a/crates/mobile/src/client_ffi/new_session.rs +++ b/crates/mobile/src/client_ffi/new_session.rs @@ -59,6 +59,17 @@ impl CoreClient { on_runtime(async move { client.create_session(new_session).await }).await } + /// Fork `source_id` through its last completed reply into a side chat + /// (upstream #899's "Fork into Side Chat"): the engine copies the + /// history and publishes the row; a side chat's fork lists as its + /// sibling. Returns the new **scoped** chat id. An engine without + /// the RPC answers `Unsupported` (hide the entry point); nothing + /// completed yet → `HostError` with the engine's reason. + pub async fn fork_side_chat(&self, source_id: String) -> CoreResult { + let client = self.client.clone(); + on_runtime(async move { client.fork_side_chat(&source_id).await }).await + } + /// Create (or find) the project for `path` on an engine device /// (`Mutate createSpace`); returns the **scoped** space id. A space /// on the same device in the same folder dedupes to its id (case- and @@ -278,6 +289,7 @@ mod tests { branch: Some("main".into()), cwd: None, title: Some("My chat".into()), + parent_chat_id: None, } .try_into() .unwrap(); @@ -289,6 +301,21 @@ mod tests { ); assert!(ok.config.is_some()); assert_eq!(ok.branch.as_deref(), Some("main")); + assert_eq!(ok.parent_chat_id, None); + let side: mc::NewSession = NewSession { + target: SessionTarget::Projectless { + device_id: "engine:v1:dev".into(), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some("engine:v1:chat".into()), + } + .try_into() + .unwrap(); + assert_eq!(side.parent_chat_id.as_deref(), Some("engine:v1:chat")); + assert!(matches!(side.target, mc::SessionTarget::Projectless { .. })); let bad = NewSession { target: SessionTarget::Projectless { device_id: "engine:v1:dev".into(), @@ -303,6 +330,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }; let error = TryInto::::try_into(bad).unwrap_err(); assert!(matches!(error, crate::client_ffi::CoreError::InvalidArgument { .. })); diff --git a/crates/mobile/src/client_ffi/session.rs b/crates/mobile/src/client_ffi/session.rs index dc45fb8074..0124f8efa5 100644 --- a/crates/mobile/src/client_ffi/session.rs +++ b/crates/mobile/src/client_ffi/session.rs @@ -494,6 +494,47 @@ pub struct SessionHandle { pub(crate) inner: mc::SessionHandle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent the session's agent spawned (one linked spawn chip), as the +/// side-chats sheet lists it (upstream #899). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SubagentInfo { + /// Its transcript doc — `CoreClient.open_subagent`. + pub doc_id: String, + /// What the spawn asked for ("Audit grapheme handling"). + pub title: String, + pub state: SubagentState, + /// One-line live tail of its output, while it runs. + pub tail: Option, + /// The model the spawn picked, when it named one. + pub model: Option, + /// When the spawning reply was written (epoch ms). + pub spawned_at_ms: i64, +} + +impl From for SubagentInfo { + fn from(r: mc::SubagentRow) -> Self { + Self { + doc_id: r.doc_id, + title: r.title, + state: match r.state { + mc::SubagentState::Running => SubagentState::Running, + mc::SubagentState::Done => SubagentState::Done, + mc::SubagentState::Failed => SubagentState::Failed, + }, + tail: r.tail, + model: r.model, + spawned_at_ms: r.spawned_at_ms, + } + } +} + impl SessionHandle { pub(crate) fn new(inner: mc::SessionHandle) -> Arc { Arc::new(Self { inner }) @@ -522,6 +563,22 @@ impl SessionHandle { self.inner.composer().as_ref().clone().into() } + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). The spawn chips are the index — no listing RPC. + pub fn subagents(&self) -> Vec { + self.inner + .subagents() + .into_iter() + .map(Into::into) + .collect() + } + + /// Whether this handle is a read-only subagent transcript (no + /// composer, no sends, no seen marks) — the viewer's contract. + pub fn is_subagent(&self) -> bool { + self.inner.is_subagent() + } + pub fn transcript_status(&self) -> TranscriptStatus { let facts = self.inner.transcript_status(); TranscriptStatus { @@ -714,6 +771,16 @@ impl CoreClient { )) } + /// Open (or return the open) subagent transcript — the doc id a + /// spawn chip carries (`{chatId}--sub--{id}`, scoped). Read-only: + /// the viewer shows no composer; live while the subagent runs, the + /// persisted doc once finished, live again if it resumes. + pub fn open_subagent(&self, doc_id: String) -> CoreResult> { + Ok(SessionHandle::new( + self.client.open_subagent(&doc_id)?, + )) + } + /// An already-open session. pub fn session(&self, chat_id: String) -> Option> { self.client.session(&chat_id).map(SessionHandle::new) @@ -1023,4 +1090,37 @@ mod tests { "[docs](roboco-file:docs/)" ); } + + /// The FFI `SubagentInfo` maps one-to-one off the client's + /// `SubagentRow` (upstream #899's sheet record). + #[test] + fn subagent_info_maps_off_the_client_row() { + let row = mc::SubagentRow { + doc_id: "engine:v1:chat--sub--s1".into(), + title: "Audit grapheme handling".into(), + state: mc::SubagentState::Running, + tail: Some("Reading RowView.swift…".into()), + model: Some("haiku".into()), + spawned_at_ms: 1_000, + }; + let info: SubagentInfo = row.into(); + assert_eq!(info.doc_id, "engine:v1:chat--sub--s1"); + assert_eq!(info.title, "Audit grapheme handling"); + assert_eq!(info.state, SubagentState::Running); + assert_eq!(info.tail.as_deref(), Some("Reading RowView.swift…")); + assert_eq!(info.model.as_deref(), Some("haiku")); + assert_eq!(info.spawned_at_ms, 1_000); + let done: SubagentInfo = mc::SubagentRow { + state: mc::SubagentState::Done, + ..row.clone() + } + .into(); + assert_eq!(done.state, SubagentState::Done); + let failed: SubagentInfo = mc::SubagentRow { + state: mc::SubagentState::Failed, + ..row + } + .into(); + assert_eq!(failed.state, SubagentState::Failed); + } } diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index ce9afe3bb2..616af0b603 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -628,6 +628,10 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option, + /// Agents running under this chat right now — running subagents of + /// its open transcript plus its working side chats (upstream #899's + /// "● N" pill). Counts only while a session is open. + pub running_subagents: u32, pub last_activity_ms: i64, /// `now` / `34m` / `4h` / `2d` at derive time. pub time_label: String, @@ -670,6 +674,7 @@ impl From<&mc::SessionRow> for SessionRow { indicator: r.indicator.into(), host_indicator: r.host_indicator.into(), working_since_ms: r.working_since_ms, + running_subagents: r.running_subagents, last_activity_ms: r.last_activity_ms, time_label: r.time_label.clone(), created_at_ms: r.created_at_ms, @@ -1191,6 +1196,10 @@ pub struct NewSession { pub branch: Option, pub cwd: Option, pub title: Option, + /// A side chat of this chat (upstream #899): runs in its parent's + /// checkout and host (`target`, `cwd` and `branch` ignored), and + /// lists under the parent. + pub parent_chat_id: Option, } impl TryFrom for mc::NewSession { @@ -1208,6 +1217,7 @@ impl TryFrom for mc::NewSession { branch: new.branch, cwd: new.cwd, title: new.title, + parent_chat_id: new.parent_chat_id, }) } } diff --git a/crates/mobile/src/layout/display.rs b/crates/mobile/src/layout/display.rs index 89665d0506..eeea7b3ccd 100644 --- a/crates/mobile/src/layout/display.rs +++ b/crates/mobile/src/layout/display.rs @@ -189,6 +189,10 @@ pub enum WidgetKind { ToolRail { trunk_x: f32, bend: f32, branch_end: f32, row_mid: f32, tops: Vec, heights: Vec }, /// Tap target toggling one tool row's inline detail. ToolToggle { detail: u64, open: bool }, + /// Tap target over a whole subagent spawn card: opens the subagent's + /// transcript (upstream #899). `doc_id` is the scoped doc + /// (`{chatId}--sub--{id}`); `frozen` = it finished (done/failed). + SubagentLink { doc_id: String, title: String, frozen: bool }, /// Shimmer sweep over the active group's title (text drawn by the canvas /// underneath; the painter re-draws those runs brighter through a moving /// gradient). diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index 20a8ab0b2c..f7964d1be2 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -662,6 +662,87 @@ fn running_subagent_shows_a_spinner_after_its_spawn_resolves() { assert!(failed.display(0).unwrap().runs.iter().any(|r| r.color == display::ColorRole::Danger), "failed subagent is tinted danger"); } +/// A spawn card whose engine-stamped doc exists is a link (upstream +/// #899): the whole card is a tap target carrying the doc id, the spawn's +/// description as the title, and `frozen` once the subagent finished. A +/// spawn chip without a ref (the engine never linked one) renders no +/// link — nothing to open. +#[test] +fn spawn_cards_with_a_doc_ref_become_subagent_links() { + use roboco_doc::parts::{MessagePart, MessageStatus, SubagentStatus}; + use roboco_doc::schema::{MessageRole, SessionMessageEntry}; + let spawn = |subagent_ref: Option, status: SubagentStatus| MessagePart::Tool { + id: "k1".into(), + call: roboco_proto::ToolCall::Unknown { + name: "Agent: Audit grapheme handling".into(), + input: Some(serde_json::json!({ "prompt": "x" })), + }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref, + subagent_status: Some(status), + subagent_tail: None, + }; + let frame_for = |part: MessagePart| { + let mut w = worker(390.0); + w.input = TranscriptInput { + entries: vec![Arc::new(SessionMessageEntry { + id: "a".into(), + role: MessageRole::Assistant, + parts: vec![part], + created_at: 0, + device_id: String::new(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + })], + ..Default::default() + }; + w.pass() + }; + let link = |f: &LayoutFrame| { + f.display(0) + .unwrap() + .widgets + .iter() + .find_map(|w| match &w.kind { + display::WidgetKind::SubagentLink { doc_id, title, frozen } => { + Some((doc_id.clone(), title.clone(), *frozen, (w.x, w.y, w.w, w.h))) + } + _ => None, + }) + }; + // Running: a live link over the whole card. + let running = frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Running)); + let (doc_id, title, frozen, _bounds) = link(&running).expect("running spawn card is a link"); + assert_eq!(doc_id, "chat--sub--s1"); + assert_eq!(title, "Audit grapheme handling"); + assert!(!frozen); + // The link covers the card: as wide as the row's reading column and + // as tall as the 30pt card (T = 1.125) inside its 38pt row. + let (_, _, _, (lx, _, lw, lh)) = link(&running).expect("link bounds"); + let row = running.display(0).unwrap(); + assert!(lw > 300.0 && lw <= row.width, "link spans the card: {lw} of {}", row.width); + assert!(lx < 40.0, "the card starts at the row's margin: {lx}"); + assert!((lh - 33.75).abs() < 0.5, "link covers the 30pt card (T = 1.125): {lh}"); + // Done/Failed: frozen links. + let (_, _, frozen, _) = link(&frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Done))) + .expect("done spawn card is a link"); + assert!(frozen); + let (_, _, frozen, _) = link(&frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Failed))) + .expect("failed spawn card is a link"); + assert!(frozen); + // No doc ref: no link at all (a stray chip must not open anything). + let unlinked = frame_for(spawn(None, SubagentStatus::Running)); + assert!(link(&unlinked).is_none(), "a spawn without a doc ref is not a link"); +} + #[test] fn links_get_hit_regions() { for md in [ diff --git a/crates/mobile/src/layout/tools.rs b/crates/mobile/src/layout/tools.rs index ff802dc004..888731445c 100644 --- a/crates/mobile/src/layout/tools.rs +++ b/crates/mobile/src/layout/tools.rs @@ -54,6 +54,19 @@ pub(crate) struct ToolLine { pub key: u64, pub open: bool, pub body: Vec, + /// A spawn card whose subagent doc exists: tapping opens its + /// transcript (upstream #899). `None` for every non-agent line. + pub link: Option, +} + +/// The subagent doc a spawn card links to. +pub(crate) struct SubagentLink { + /// The doc id (`{chatId}--sub--{id}`, scoped on this client). + pub doc_id: String, + /// The spawn's description — the viewer's title. + pub title: String, + /// Done/Failed → the subagent is no longer live. + pub frozen: bool, } pub(crate) struct ToolGroup { @@ -786,6 +799,28 @@ impl RowBuilder { match part { MessagePart::Tool { call, is_error, resolved, subagent_ref, subagent_status, .. } => { let (label, detail) = roboco_proto::view::tool_chip_content(call); + // A spawn card whose doc exists is a link (upstream + // #899): tapping opens the subagent's transcript. + // Computed before `detail` is shadowed by the + // prepared text below, and only for agent cards. + let link = match (agents, subagent_ref) { + (true, Some(doc_id)) => Some(SubagentLink { + doc_id: doc_id.clone(), + title: detail + .trim() + .lines() + .next() + .map(str::trim) + .filter(|t| !t.is_empty()) + .unwrap_or("Subagent") + .to_owned(), + frozen: matches!( + subagent_status, + Some(SubagentStatus::Done | SubagentStatus::Failed) + ), + }), + _ => None, + }; // Subagent lifecycle is distinct from `resolved`: under // eager-done the spawn call resolves while the subagent // still runs (desktop transcript.rs `running`/`failed`). @@ -822,6 +857,7 @@ impl RowBuilder { key: dkey, open, body, + link, }); } MessagePart::Reasoning { text, .. } => { @@ -849,6 +885,7 @@ impl RowBuilder { key: dkey, open, body, + link: None, }); } _ => {} @@ -1108,6 +1145,19 @@ fn place_agents(t: &ToolGroup, px: Px, x: f32, y: f32, cw: f32, mut out: Option< let s = d(px, 14.0); o.widget(WidgetKind::Spinner, (x + cw - d(px, 8.0) - s, card_y + (card_h - s) / 2.0, s, s), None); } + // The spawn card is a link once the engine stamped the subagent's + // doc: a full-card tap target (upstream #899's `SubagentLink`). + if let Some(link) = &line.link { + o.widget( + WidgetKind::SubagentLink { + doc_id: link.doc_id.clone(), + title: link.title.clone(), + frozen: link.frozen, + }, + (x, card_y, cw, card_h), + None, + ); + } } h } @@ -1120,6 +1170,7 @@ pub(crate) fn heap_bytes(t: &ToolGroup) -> usize { l.label.p.heap_bytes() + l.detail.as_ref().map_or(0, |d| d.p.heap_bytes()) + l.badge.as_ref().map_or(0, |b| b.1.p.heap_bytes()) + + l.link.as_ref().map_or(0, |link| link.doc_id.capacity() + link.title.capacity()) + l.body .iter() .map(|b| match b { From 5bbd297364673afb1331cb0a86f803f5db471975 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 18:48:15 +0700 Subject: [PATCH 02/13] fix(mobile-client): the lost SessionHandle::subagents/is_subagent edit + the Arc import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed edit batch (one block not matching) dropped the SessionHandle half of the subagent surface — only the snapshot method had landed. Also new_session.rs never imported std::sync::Arc. --- crates/mobile-client/src/new_session.rs | 3 ++- crates/mobile-client/src/session/mod.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/mobile-client/src/new_session.rs b/crates/mobile-client/src/new_session.rs index 33bde7a1eb..050386c808 100644 --- a/crates/mobile-client/src/new_session.rs +++ b/crates/mobile-client/src/new_session.rs @@ -14,6 +14,7 @@ //! helper here. 08's `send` reads the pending chat's `cwd`/`config`. use std::collections::HashMap; +use std::sync::Arc; use chrono::{DateTime, Utc}; use roboco_engine_client::registry::{EngineKey, ScopedId}; @@ -386,7 +387,7 @@ impl Client { /// parent's (the checkout is the parent's). Returns the scoped id. async fn create_side_chat( &self, - inner: &Arc, + inner: &Arc, parent_id: &str, config: Option, title: Option, diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index 53476cffd0..1f72436bfd 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -1114,6 +1114,18 @@ impl SessionHandle { self.core.snapshot() } + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). The spawn chips are the index — no listing RPC. + pub fn subagents(&self) -> Vec { + self.core.snapshot().subagents() + } + + /// Whether this handle is a read-only subagent transcript (no + /// composer, no sends, no seen marks) — the viewer's contract. + pub fn is_subagent(&self) -> bool { + self.core.read_only + } + /// The raw subscription (Rust consumers). pub fn subscribe(&self) -> watch::Receiver> { self.core.snapshot_tx_subscribe() From 3d10c3be5eed61767cc3e2172e2add530a936040 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 19:48:43 +0700 Subject: [PATCH 03/13] test(mobile-client): the background-subagent binding (claude-code run_in_background) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's exact shape, driven through a channel-fed harness: the spawn call's tool result returns at once, the parent turn completes (parks), and the subagent's nested traffic arrives afterwards — sessions.rs routes it before the parked gate, so the doc streams and the chip binds (running dots), then freezes on the agent's Done. Asserts the chip state, the doc id, the tail, the running count, the live open, and the frozen read. --- .../mobile-client/tests/side_chats_engine.rs | 245 +++++++++++++++++- 1 file changed, 241 insertions(+), 4 deletions(-) diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index 38a651e713..2834fab728 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -37,14 +37,23 @@ enum Reply { /// Spawn a subagent and keep the turn (and it) running: the stream /// stays open after the nested text. SpawnLive, + /// The user's claude-code background shape: the harness's stream is a + /// channel the TEST drives — the parent turn completes (a parked, + /// steerable session), and the subagent's nested traffic arrives + /// afterwards, exactly like claude's `run_in_background` lane (the + /// `task_notification` re-keying of `agent_tasks`). + Background, } /// A mock harness that records every `RunRequest` and answers with the /// scenario's event stream. `SpawnLive` parks the stream (the turn never -/// completes), so the spawned subagent stays `Running`. +/// completes), so the spawned subagent stays `Running`. `Background` +/// answers from a channel the test feeds. struct ScenarioHarness { reply: Reply, requests: Arc>>, + /// `Reply::Background`: the events the test feeds. + background: std::sync::Mutex>>>, } #[async_trait] @@ -79,6 +88,15 @@ impl Harness for ScenarioHarness { _controls: RunControls, ) -> Result>, HarnessError> { self.requests.lock().unwrap().push(request); + if let Some(rx) = self.background.lock().unwrap().take() { + // The test drives every event: the parent turn's completion, + // then the background subagent's nested traffic. + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + return Ok(futures::stream::unfold(rx, |rx| async move { + rx.lock().await.recv().await.map(|item| (item, rx)) + }) + .boxed()); + } let reply = self.reply; let events: Vec> = match reply { Reply::Plain => vec![ @@ -200,11 +218,31 @@ impl Rig { /// Assemble the engine with the scenario harness, a parent chat with a /// known checkout, serve it, and pair a client. async fn rig(reply: Reply) -> Rig { + rig_with_background(reply).await.0 +} + +/// [`rig`] plus the background lane: when `reply` is `Reply::Background`, +/// the returned sender feeds the harness's event stream (the test drives +/// the parent turn and the subagent's nested traffic itself). +async fn rig_with_background( + reply: Reply, +) -> (Rig, Option>>) { let engine_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap(); let registry = HarnessRegistry::new(); let requests = Arc::new(Mutex::new(Vec::new())); - registry.register(Arc::new(ScenarioHarness { reply, requests: requests.clone() })); + let background = if reply == Reply::Background { + let (tx, rx) = tokio::sync::mpsc::channel(32); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + let (background_tx, background_rx) = background; + registry.register(Arc::new(ScenarioHarness { + reply, + requests: requests.clone(), + background: std::sync::Mutex::new(background_rx), + })); let core = EngineCore::assemble_with_profile( roboco_engine::EngineProfile::local(engine_dir.path()).unwrap(), Arc::new(registry), @@ -244,14 +282,15 @@ async fn rig(reply: Reply) -> Rig { .is_some_and(|engine| engine.rows.chats.iter().any(|chat| chat.id == "parent")) }) .await; - Rig { + let rig = Rig { client, core, engine_id: record.engine_id, engine_dir, data_dir, requests, - } + }; + (rig, background_tx) } impl Rig { @@ -695,3 +734,201 @@ async fn a_finished_subagent_clears_the_count_and_still_reads() { tokio::time::sleep(Duration::from_millis(50)).await; } } + +/// The user's claude-code background shape (run_in_background): the spawn +/// call's tool result returns at once ("Async agent launched") and the +/// parent turn completes, while the subagent's nested traffic arrives +/// afterwards — the run loop stays parked and routes it into the +/// subagent's doc (sessions.rs: "Handled BEFORE the parked gate so a +/// parked session's background traffic still reaches the subagent doc"). +/// The card binds to the doc (a link with running dots), the doc opens +/// live and keeps streaming, and the chip freezes when the agent's Done +/// lands. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_background_subagent_binds_after_its_tool_result_and_freezes_on_done() { + let (rig, sender) = rig_with_background(Reply::Background).await; + let sender = sender.expect("the background lane"); + let parent = rig.parent_id(); + let parent_handle = rig.client.open_session(&parent).unwrap(); + let request_cwd = "/checkout/parent".to_owned(); + let send = parent_handle + .send(SendRequest::text("run an agent in the background")) + .await + .expect("send"); + assert!(matches!(send, roboco_mobile_client::SendOutcome::Started { .. })); + + // Phase 1 — the parent turn: the spawn call, its IMMEDIATE result + // (the async-launch ack resolves the tool call), the parent's text, + // and the turn's Done (the steerable session parks, not ends). + let event = |e: AgentEvent| -> Result { Ok(e) }; + sender + .send(event(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request_cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::ToolCall { + id: "tool-1".into(), + call: ToolCall::Unknown { + name: "Agent: Audit the repo".into(), + input: Some(serde_json::json!({ "prompt": "audit", "run_in_background": true })), + }, + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::ToolResult { + id: "tool-1".into(), + is_error: false, + output: Some("Async agent launched…".into()), + diff: None, + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::TextDelta { + text: "parent turn done".into(), + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + })) + .await + .unwrap(); + + // The parent's reply landed; the subagent has not spoken yet, so + // nothing is listed (the chip IS the index). + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let answered = parent_handle + .snapshot() + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("parent turn done"))); + if answered { + break; + } + assert!(Instant::now() < deadline, "the parent turn never landed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(parent_handle.subagents().is_empty(), "no subagent yet"); + + // Phase 2 — the background agent speaks, AFTER the turn completed. + // The engine stamps the (already-resolved) chip with the doc ref and + // Running, and routes the text into the subagent's own doc. + sender + .send(event(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::TextDelta { + text: "background: scanning the tree".into(), + }), + })) + .await + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let rows = loop { + let rows = parent_handle.subagents(); + if !rows.is_empty() { + break rows; + } + assert!(Instant::now() < deadline, "the background subagent never bound"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); + assert_eq!(rows[0].tail.as_deref(), Some("background: scanning the tree")); + // The running count lands on the row (the card's dots). + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 1 { + break; + } + assert!(Instant::now() < deadline, "the background count never landed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The doc opens read-only and streams the background output. + let viewer = rig + .client + .open_subagent(&rig.scope("parent--sub--tool-1")) + .expect("open the background subagent"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let streaming = viewer.snapshot().entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("scanning the tree")))); + if streaming { + break; + } + assert!(Instant::now() < deadline, "the background doc never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Phase 3 — the agent finishes: the chip freezes (Done), the count + // clears, and the transcript still reads. + sender + .send(event(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + })) + .await + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let rows = parent_handle.subagents(); + if rows.len() == 1 && rows[0].state == SubagentState::Done { + break; + } + assert!(Instant::now() < deadline, "the background subagent never froze: {rows:?}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 0 { + break; + } + assert!(Instant::now() < deadline, "the frozen count never cleared"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let still_reads = viewer + .snapshot() + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("scanning the tree")))); + assert!(still_reads, "the frozen transcript still reads"); +} From e13da957d0d52a3639023eb9dc7ad414455370dc Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 20:46:02 +0700 Subject: [PATCH 04/13] fix(mobile-client): side chats and subagents compile and match the engine - new_session: import ClientInner (create_side_chat's signature named it without a use). - subagents_of titles a row with subagent_title, a port of the desktop's subagent_tab_title (bare task, description/prompt fallback, 40-char cap), re-exported for the layout's card link. - A read-only subagent core skips the WatchQueue task and refuses sends (InvalidArgument); it never writes the store (no seen marks, no pill). - set_running_subagents removes the entry on 0 instead of storing zeros. - side_chats_engine: Reply::Background was missing from the reply match; the run pushed `request` before reading it (use after move); "mock".into() has no From<&str> for HarnessId; the background stream held a tokio MutexGuard while moving the Arc. The rig now waits for the derived parent row (create_side_chat reads it) and the fork test waits for a COMPLETE reply. The chip tail asserts None: the engine stamps lifecycle only (sessions.rs subagent_chip_update; live tails were rejected). Co-Authored-By: Claude --- crates/mobile-client/src/lib.rs | 1 + crates/mobile-client/src/new_session.rs | 2 +- crates/mobile-client/src/session/mod.rs | 132 ++++++++++++++++-- crates/mobile-client/src/workspace/mod.rs | 14 +- .../mobile-client/tests/side_chats_engine.rs | 62 ++++++-- 5 files changed, 184 insertions(+), 27 deletions(-) diff --git a/crates/mobile-client/src/lib.rs b/crates/mobile-client/src/lib.rs index c5d86ae300..4c8b573f63 100644 --- a/crates/mobile-client/src/lib.rs +++ b/crates/mobile-client/src/lib.rs @@ -76,6 +76,7 @@ pub use session::{ OutgoingAttachment, PendingKind, PendingSend, QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem, RoomState, SendOutcome, SendRequest, SessionHandle, SessionSnapshot, SnapshotWatch, SubagentRow, SubagentState, UNDELIVERED_GRACE_MS, + subagent_title, }; // `SendState` binds once at the crate root, through workspace (07's // canonical re-export): session/pending re-exports the same type, so diff --git a/crates/mobile-client/src/new_session.rs b/crates/mobile-client/src/new_session.rs index 050386c808..616cedcae7 100644 --- a/crates/mobile-client/src/new_session.rs +++ b/crates/mobile-client/src/new_session.rs @@ -21,7 +21,7 @@ use roboco_engine_client::registry::{EngineKey, ScopedId}; use roboco_proto::ChatIndicator; use crate::workspace::ProjectView; -use crate::{Client, ClientError}; +use crate::{Client, ClientError, ClientInner}; /// How long a pending chat stays in the workspace before the engine's /// `WatchChats` echo is assumed lost: the overlay drops after this, and diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index 1f72436bfd..e78d75a743 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -64,7 +64,11 @@ pub struct SubagentRow { /// What the spawn asked for ("Audit grapheme handling"). pub title: String, pub state: SubagentState, - /// The engine's one-line live tail of its output. + /// The chip's one-line live tail, when the doc carries one. Today's + /// engine never writes it (live tails were rejected: per-delta chip + /// rewrites grew the parent doc — `crates/engine/src/sessions.rs` + /// `subagent_chip_update`), so this reads `None` in practice; older or + /// future docs that stamp `subagentTail` still surface it. pub tail: Option, /// The model the spawn picked, when it named one. pub model: Option, @@ -104,16 +108,10 @@ pub(crate) fn subagents_of(entries: &[Arc]) -> Vec SubagentState::Done, None => SubagentState::Running, }; - let detail = roboco_proto::view::tool_chip_content(call).1; // A re-spawn (a steered subagent) updates its row in place. - let title = detail.trim(); let row = SubagentRow { doc_id: doc_id.clone(), - title: if title.is_empty() { - "Subagent".to_owned() - } else { - title.to_owned() - }, + title: subagent_title(call), state, tail: subagent_tail .as_deref() @@ -132,6 +130,69 @@ pub(crate) fn subagents_of(entries: &[Arc]) -> Vec String { + use roboco_proto::ToolCall; + let (name, input) = match call { + ToolCall::Unknown { name, input } => (name.as_str(), input.as_ref()), + ToolCall::Mcp { tool, input, .. } => (tool.as_str(), input.as_ref()), + _ => return "Subagent".to_owned(), + }; + let candidates = [ + Some(name), + input.and_then(|i| i.get("description")?.as_str()), + input.and_then(|i| i.get("prompt")?.as_str()), + ]; + candidates + .into_iter() + .flatten() + .find_map(|text| title_line(strip_spawn_prefix(text), SUBAGENT_TITLE_MAX)) + .unwrap_or_else(|| "Subagent".to_owned()) +} + +/// First non-blank line of `text`, trimmed, capped at `max` chars with an +/// ellipsis (desktop `title_line`). +fn title_line(text: &str, max: usize) -> Option { + let line = text.lines().find(|l| !l.trim().is_empty())?.trim(); + let mut out: String = line.chars().take(max).collect(); + if line.chars().count() > max { + out.push('…'); + } + Some(out) +} + +/// Drop a leading "Agent"/"Task" genus (with its `:` and spacing); only a +/// word boundary strips — "Taskmaster" keeps its name (desktop +/// `strip_spawn_prefix`). +fn strip_spawn_prefix(text: &str) -> &str { + let t = text.trim(); + for prefix in ["agent", "task"] { + if t.len() >= prefix.len() + && t.is_char_boundary(prefix.len()) + && t[..prefix.len()].eq_ignore_ascii_case(prefix) + { + let rest = &t[prefix.len()..]; + if rest.is_empty() { + return ""; + } + if rest.starts_with(':') || rest.starts_with(char::is_whitespace) { + return rest.trim_start_matches(':').trim(); + } + } + } + t +} + /// Subagents whose spawn chips still run — the sessions-list "● N" pill /// (plus the working side chats the view layers on top). Pure. fn running_subagent_count(entries: &[Arc]) -> u32 { @@ -870,11 +931,16 @@ fn spawn_session_tasks(core: &Arc) { crate::runtime::shared().spawn(async move { transcript::run(transcript_core, transcript_cancel).await; }); - let queue_core = core.clone(); - let queue_cancel = cancel.clone(); - crate::runtime::shared().spawn(async move { - queue::run(queue_core, queue_cancel).await; - }); + // A read-only subagent doc has no composer and nothing queues on it: + // its `WatchQueue` would only hold a calls-connection stream open + // (and pin the doc on the engine) for a list that stays empty. + if !core.read_only { + let queue_core = core.clone(); + let queue_cancel = cancel.clone(); + crate::runtime::shared().spawn(async move { + queue::run(queue_core, queue_cancel).await; + }); + } let watch_core = core.clone(); crate::runtime::shared().spawn(async move { watch_task(watch_core, cancel).await; @@ -1176,6 +1242,13 @@ impl SessionHandle { /// Send: a new turn when idle; while working, queue (or steer, or /// stop-and-send, per `busy`). pub async fn send(&self, request: SendRequest) -> Result { + // A subagent transcript is read-only (upstream #899's viewer): a + // send would queue a run on a doc that has no chat row. + if self.core.read_only { + return Err(ClientError::InvalidArgument { + message: "a subagent transcript is read-only".into(), + }); + } self.core.clone().send(request).await } @@ -1733,6 +1806,39 @@ mod tests { assert_eq!(rows[0].spawned_at_ms, 2_000, "the newest spawn wins"); } + /// Subagent titles match the desktop's tab titles (`subagent_tab_title`): + /// the bare task, the input's description/prompt for a bare spawn, + /// word-boundary genus stripping, a capped first line. + #[test] + fn subagent_titles_match_the_desktop_tabs() { + use roboco_proto::ToolCall; + let unknown = |name: &str, input: Option| ToolCall::Unknown { + name: name.into(), + input, + }; + assert_eq!(subagent_title(&unknown("Agent: scan repo", None)), "scan repo"); + assert_eq!( + subagent_title(&unknown( + "Task", + Some(serde_json::json!({ + "description": "Agent: audit the auth flow", + "prompt": "very long instructions…", + })) + )), + "audit the auth flow" + ); + assert_eq!( + subagent_title(&unknown("Agent", Some(serde_json::json!({ "prompt": "line one\nline two" })))), + "line one" + ); + assert_eq!(subagent_title(&unknown("Taskmaster", None)), "Taskmaster"); + assert_eq!(subagent_title(&unknown("agent", None)), "Subagent"); + let long = subagent_title(&unknown(&"x".repeat(120), None)); + assert_eq!(long.chars().count(), SUBAGENT_TITLE_MAX + 1); + assert!(long.ends_with('…')); + assert_eq!(subagent_title(&ToolCall::Exec { command: "ls".into() }), "Subagent"); + } + /// The pill counts only running subagents. #[test] fn running_subagent_count_counts_only_the_running() { diff --git a/crates/mobile-client/src/workspace/mod.rs b/crates/mobile-client/src/workspace/mod.rs index 468e50ea09..5077567815 100644 --- a/crates/mobile-client/src/workspace/mod.rs +++ b/crates/mobile-client/src/workspace/mod.rs @@ -299,9 +299,19 @@ impl WorkspaceStore { /// publication; `0` clears it. Recomputes on every change — a count /// that did not move changes nothing (the row hash holds it). pub(crate) fn set_running_subagents(&self, chat_id: &str, count: u32) { + // A leaf lock: nothing else is taken while it is held, so the + // session's publish path may call this from any lock state the + // crate's order allows (it runs after the session state lock). let mut counts = lock(&self.running_subagents); - if counts.insert(chat_id.to_owned(), count) != Some(count) { - drop(counts); + // `0` removes the entry, so every chat ever opened does not leave + // a zero behind (and clearing an absent count is a no-op). + let changed = if count == 0 { + counts.remove(chat_id).is_some() + } else { + counts.insert(chat_id.to_owned(), count) != Some(count) + }; + drop(counts); + if changed { self.notify.notify_one(); } } diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index 2834fab728..777a115d81 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -87,19 +87,22 @@ impl Harness for ScenarioHarness { request: RunRequest, _controls: RunControls, ) -> Result>, HarnessError> { - self.requests.lock().unwrap().push(request); - if let Some(rx) = self.background.lock().unwrap().take() { + self.requests.lock().unwrap().push(request.clone()); + let background = self.background.lock().unwrap().take(); + if let Some(rx) = background { // The test drives every event: the parent turn's completion, - // then the background subagent's nested traffic. - let rx = Arc::new(tokio::sync::Mutex::new(rx)); - return Ok(futures::stream::unfold(rx, |rx| async move { - rx.lock().await.recv().await.map(|item| (item, rx)) + // then the background subagent's nested traffic. The stream + // stays open until the test drops its sender. + return Ok(futures::stream::unfold(rx, |mut rx| async move { + let item = rx.recv().await?; + Some((item, rx)) }) .boxed()); } let reply = self.reply; let events: Vec> = match reply { - Reply::Plain => vec![ + // A background rig's later runs (none today) answer plainly. + Reply::Plain | Reply::Background => vec![ Ok(AgentEvent::SessionStarted { harness: HarnessId::Mock, model: "mock-1".into(), @@ -282,6 +285,15 @@ async fn rig_with_background( .is_some_and(|engine| engine.rows.chats.iter().any(|chat| chat.id == "parent")) }) .await; + // The workspace store derives off the fleet asynchronously: the side + // chat create reads the parent's ROW (host, checkout, sandbox), so + // wait for the derived row too, not only the fleet frame. + let scoped_parent = client.scope(&record.engine_id, "parent"); + let deadline = Instant::now() + Duration::from_secs(15); + while client.workspace().session(&scoped_parent).is_none() { + assert!(Instant::now() < deadline, "the parent row never derived"); + tokio::time::sleep(Duration::from_millis(50)).await; + } let rig = Rig { client, core, @@ -318,6 +330,24 @@ impl Rig { } } + /// Wait until the parent's reply is COMPLETE (not merely streaming): + /// the engine's fork boundary is the last completed assistant entry. + async fn wait_completed(&self) { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let completed = handle.snapshot().entries.iter().any(|entry| { + entry.role == roboco_doc::MessageRole::Assistant + && entry.status == Some(roboco_doc::MessageStatus::Complete) + }); + if completed { + return; + } + assert!(Instant::now() < deadline, "the reply never completed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + /// The engine's own row for a raw chat id. fn engine_row(&self, raw: &str) -> roboco_proto::Chat { self.core @@ -359,6 +389,7 @@ async fn fork_side_chat_copies_history_under_its_parent() { // A completed turn, then the fork. rig.ask("hello").await; + rig.wait_completed().await; let fork = rig.client.fork_side_chat(&parent).await.expect("fork"); assert!(roboco_engine_client::registry::ScopedId::is_scoped(&fork)); let raw_fork = roboco_engine_client::registry::ScopedId::parse(&fork) @@ -434,7 +465,7 @@ async fn create_side_chat_runs_in_the_parents_checkout() { let rig = rig(Reply::Plain).await; let parent = rig.parent_id(); let config = ChatConfig { - harness: "mock".into(), + harness: ProtoHarnessId::Mock, model: Some("mock-1".into()), reasoning: None, model_options: Default::default(), @@ -590,7 +621,10 @@ async fn the_subagent_doc_streams_and_counts_while_it_runs() { assert_eq!(rows[0].state, SubagentState::Running); assert_eq!(rows[0].title, "Audit the repo"); assert_eq!(rows[0].model.as_deref(), Some("mock-1")); - assert_eq!(rows[0].tail.as_deref(), Some("subagent: reading the tree")); + // No live tail: the engine's chip carries lifecycle only (tails were + // rejected — `subagent_chip_update` in crates/engine/src/sessions.rs); + // the nested text lands in the subagent's own doc instead. + assert_eq!(rows[0].tail, None); assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); // The parent's row counts it (the "● N" pill). @@ -808,7 +842,12 @@ async fn a_background_subagent_binds_after_its_tool_result_and_freezes_on_done() .unwrap(); // The parent's reply landed; the subagent has not spoken yet, so - // nothing is listed (the chip IS the index). + // nothing is listed (the chip IS the index): the engine stamps + // `subagentRef` on the FIRST tagged event, never on the ToolCall + // (sessions.rs subagent routing). The real claude driver binds at spawn + // time because it seeds a tagged `UserMessage` with the spawn's prompt + // right after the call (claude/normalize.rs); this harness omits that + // seed, which exercises the in-place stamp on a finished entry. let deadline = Instant::now() + Duration::from_secs(20); loop { let answered = parent_handle @@ -851,7 +890,8 @@ async fn a_background_subagent_binds_after_its_tool_result_and_freezes_on_done() assert_eq!(rows.len(), 1); assert_eq!(rows[0].state, SubagentState::Running); assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); - assert_eq!(rows[0].tail.as_deref(), Some("background: scanning the tree")); + // Lifecycle only on the chip (no tail); the text is in the doc below. + assert_eq!(rows[0].tail, None); // The running count lands on the row (the card's dots). let deadline = Instant::now() + Duration::from_secs(15); loop { From 09df3f7908f9d49560f719ed6dd8d00195ac9495 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 20:46:03 +0700 Subject: [PATCH 05/13] fix(mobile): FFI defaults for the side-chat fields; card link titles like desktop SessionRow.running_subagents defaults to 0 and NewSession.parent_chat_id to None in the generated bindings, so existing Kotlin constructor calls keep compiling. The subagent card link takes its title from the client's subagent_title (the desktop tab title) instead of the chip detail line. Co-Authored-By: Claude --- crates/mobile/src/client_ffi/types.rs | 2 ++ crates/mobile/src/layout/tools.rs | 13 +++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index 616af0b603..9c9b7f2d6c 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -631,6 +631,7 @@ pub struct SessionRow { /// Agents running under this chat right now — running subagents of /// its open transcript plus its working side chats (upstream #899's /// "● N" pill). Counts only while a session is open. + #[uniffi(default = 0)] pub running_subagents: u32, pub last_activity_ms: i64, /// `now` / `34m` / `4h` / `2d` at derive time. @@ -1199,6 +1200,7 @@ pub struct NewSession { /// A side chat of this chat (upstream #899): runs in its parent's /// checkout and host (`target`, `cwd` and `branch` ignored), and /// lists under the parent. + #[uniffi(default = None)] pub parent_chat_id: Option, } diff --git a/crates/mobile/src/layout/tools.rs b/crates/mobile/src/layout/tools.rs index 888731445c..7ad5d67c24 100644 --- a/crates/mobile/src/layout/tools.rs +++ b/crates/mobile/src/layout/tools.rs @@ -801,19 +801,12 @@ impl RowBuilder { let (label, detail) = roboco_proto::view::tool_chip_content(call); // A spawn card whose doc exists is a link (upstream // #899): tapping opens the subagent's transcript. - // Computed before `detail` is shadowed by the - // prepared text below, and only for agent cards. + // Only for agent cards; titled like the client's + // subagent rows (the desktop's tab title). let link = match (agents, subagent_ref) { (true, Some(doc_id)) => Some(SubagentLink { doc_id: doc_id.clone(), - title: detail - .trim() - .lines() - .next() - .map(str::trim) - .filter(|t| !t.is_empty()) - .unwrap_or("Subagent") - .to_owned(), + title: roboco_mobile_client::subagent_title(call), frozen: matches!( subagent_status, Some(SubagentStatus::Done | SubagentStatus::Failed) From 8ce6222d29fd62958ac2b2e79482d194ea38be7e Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Thu, 15 Oct 2026 21:19:37 +0700 Subject: [PATCH 06/13] feat(android): spawn cards open the subagent's live read-only transcript (android-next#06) WidgetKind.SubagentLink becomes a full-card tap target (a transcript control) that calls TranscriptActions.openSubagent. SubagentRoute opens the doc read-only through CoreClient.openSubagent and binds the layout engine to it: the engine's WatchDocMessages stream, the same one desktop's subagent tab watches, so a claude-code run_in_background subagent keeps streaming after its spawn call resolved and stops once done. The subtitle follows the spawn chip's state; nested cards push another viewer. Co-Authored-By: Claude --- .../roboco/android/session/SessionScreen.kt | 26 +- .../android/sidechats/SideChatsModel.kt | 226 ++++++++ .../android/sidechats/SubagentViewer.kt | 532 ++++++++++++++++++ .../sh/roboco/android/transcript/RowMenu.kt | 5 +- .../sh/roboco/android/transcript/RowWidget.kt | 8 + .../android/transcript/TranscriptActions.kt | 7 + .../android/transcript/TranscriptControl.kt | 3 +- .../transcript/widgets/SubagentLink.kt | 59 ++ 8 files changed, 862 insertions(+), 4 deletions(-) create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt create mode 100644 apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt index 4c924cd47c..8297d74dbc 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ImageBitmap @@ -116,6 +117,13 @@ import sh.roboco.android.transcript.widgets.imageTileRadius * (`DraftKey.Chat(engineId, chatId)`). The route passes `core.engineId`; * `null` — a fake core in tests — leaves the drafts unwired: no saver, * no seed text, exactly the pre-84 behaviour + * @param onOpenSubagent android-next #06: a subagent spawn card was tapped + * (the doc id, the spawn's title, whether it had finished). `null` — + * tests, previews — leaves the cards inert + * @param headerAccessory android-next #06: the side chats button, the + * header's second bar item left of the ⋯; `null` = none + * @param overlay android-next #06: composed last, over the whole screen — + * the Side Chats & Agents sheet */ @Composable fun SessionScreen( @@ -129,6 +137,9 @@ fun SessionScreen( queueEditPort: QueueEditPort? = null, queueActions: QueueActions? = null, engineId: String? = null, + onOpenSubagent: ((docId: String, title: String, frozen: Boolean) -> Unit)? = null, + headerAccessory: (@Composable () -> Unit)? = null, + overlay: @Composable () -> Unit = {}, ) { // 1. The per-screen state: the same instance the route holds. The // first main-thread read creates the transcript (76's factory @@ -249,6 +260,12 @@ fun SessionScreen( ) }, ) + // android-next #06: a spawn card opens its subagent through the route's + // opener, read at tap time (the actions are remembered once). + val currentOpenSubagent by rememberUpdatedState(onOpenSubagent) + val openSubagent: (String, String, Boolean) -> Unit = remember { + { docId: String, title: String, frozen: Boolean -> currentOpenSubagent?.invoke(docId, title, frozen) } + } val actions = remember(controller, attachments, viewer, router) { val openImage: (String, Rect, ImageBitmap?, String?) -> Unit = { reference, source, bitmap, label -> viewer?.show( @@ -274,9 +291,10 @@ fun SessionScreen( openImage = openImage, loadImage = binding::load, cachedImage = binding::cached, + openSubagent = openSubagent, ) } else { - TranscriptActions(openLink = router::open) + TranscriptActions(openLink = router::open, openSubagent = openSubagent) } } @@ -429,6 +447,9 @@ fun SessionScreen( // IME animation frame and the runway re-sizes in the same pass // (69). `covered` (not `contentInset`) is the effective viewport // bottom inset — iOS's `adjustedContentInset.bottom`. + // android-next #06: the [overlay] (the Side Chats & Agents sheet) is + // the root's last child, over the header and the bottom stack. + Box(Modifier.fillMaxSize()) { SessionScaffold( reveal = state.reveal, header = { @@ -438,6 +459,7 @@ fun SessionScreen( edgeEngaged = edgeEngaged, onMore = onMore, statusBarTop = statusBarTop, + accessory = headerAccessory, ) }, modifier = modifier, @@ -549,4 +571,6 @@ fun SessionScreen( modifier = Modifier.fillMaxSize(), ) } + overlay() + } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt new file mode 100644 index 0000000000..541ae376fe --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt @@ -0,0 +1,226 @@ +package sh.roboco.android.sidechats + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.sessions.SessionRowVM +import sh.roboco.android.update.SemVer +import uniffi.roboco_core.CoreClient +import uniffi.roboco_core.SessionHandle +import uniffi.roboco_core.SubagentInfo +import uniffi.roboco_core.SubagentState + +/** Where a subagent stands — its spawn chip's lifecycle, never `resolved` alone. */ +enum class AgentState { Running, Done, Failed } + +/** + * One subagent of an open chat, as the sheet lists it (the port of + * upstream #899's `SubagentInfo`). A plain mirror of the FFI record so + * the sheet and its tests never touch the generated types. + */ +@Immutable +data class SideChatsAgent( + /** Its transcript doc (`{chatId}--sub--{id}`, scoped): the viewer's id. */ + val docId: String, + /** What the spawn asked for. */ + val title: String, + val state: AgentState, + /** One-line live tail of its output, while it runs. */ + val tail: String? = null, + /** The model the spawn picked, when it named one. */ + val model: String? = null, + /** When the spawning reply was written (epoch ms). */ + val spawnedAtMs: Long = 0L, +) { + val running: Boolean get() = state == AgentState.Running + + companion object { + fun from(info: SubagentInfo): SideChatsAgent = SideChatsAgent( + docId = info.docId, + title = info.title, + state = agentState(info.state), + tail = info.tail, + model = info.model, + spawnedAtMs = info.spawnedAtMs, + ) + } +} + +/** The FFI lifecycle → [AgentState]. Exhaustive: a new case fails to compile. */ +fun agentState(state: SubagentState): AgentState = when (state) { + SubagentState.RUNNING -> AgentState.Running + SubagentState.DONE -> AgentState.Done + SubagentState.FAILED -> AgentState.Failed +} + +/** + * The header button's badge (upstream #899's `syncBar`): `● N` on the + * accent while subagents run, else a quiet count of everything hanging + * off the chat (the default red would read as unread). + */ +@Immutable +data class SideChatsBadge(val running: Int, val total: Int) { + val text: String get() = if (running > 0) "● $running" else total.toString() + val accent: Boolean get() = running > 0 + + /** iOS `accessibilityValue`: "N running" while anything runs. */ + val a11yValue: String? get() = if (running > 0) "$running running" else null +} + +/** Everything the sheet and the header button render for one chat. */ +@Immutable +data class SideChatsUi( + /** Running subagents, newest spawn first. */ + val running: List = emptyList(), + /** Side chats (and agent-spawned chats) hanging off the chat, unarchived, recency order. */ + val chats: List = emptyList(), + /** Finished (done or failed) subagents, newest spawn first. */ + val finished: List = emptyList(), +) { + val total: Int get() = running.size + chats.size + finished.size + + /** `null` — the button hides — while nothing hangs off the chat (iOS `total > 0`). */ + val badge: SideChatsBadge? get() = if (total > 0) SideChatsBadge(running.size, total) else null + + companion object { + /** + * The sections from one pull: the spawn chips split by lifecycle, + * each newest first (the chips arrive in spawn order), and the + * children as the workspace orders them. Pure. + */ + fun of(agents: List, children: List): SideChatsUi = SideChatsUi( + running = agents.filter { it.running }.asReversed(), + chats = children, + finished = agents.filter { !it.running }.asReversed(), + ) + } +} + +/** + * The pull seam behind [SideChatsModel]: the generated `CoreClient` and + * `SessionHandle` are JNA objects the JVM cannot construct, so tests fake + * this. Blocking FFI — the model calls it on its IO dispatcher only. + */ +interface SideChatsSource { + /** The subagents the chat's open transcript spawned, in spawn order. */ + fun agents(): List + + /** + * `CoreClient.childSessions(parentId)` — the side chats hanging off + * [parentChatId], archived ones left out, as session-row view models. + */ + fun children(parentChatId: String): List +} + +/** + * The live [SideChatsSource]: the spawn chips of the route's own open + * handle (the chip IS the index — no listing RPC) and the workspace's + * children of the chat. + */ +class CoreSideChatsSource( + private val client: () -> CoreClient?, + private val handle: SessionHandle, +) : SideChatsSource { + override fun agents(): List = handle.subagents().map(SideChatsAgent::from) + + override fun children(parentChatId: String): List = + client()?.childSessions(parentChatId).orEmpty() + .filter { !it.archived } + .map { SessionRowVM.from(it) } +} + +/** + * One chat's side chats and subagents (android-next #06): the header + * button's badge and the sheet's rows, as [ui]. + * + * The refresh shape is the session controller's (the #609/#621 rules): + * [SessionEventHub] events only `trySend` a conflated token (a + * `SessionChanged` for the chat moves the spawn chips, a + * `WorkspaceChanged` the children — both reach the same refresh), the + * pulls run on [io], and [ui] is written from [scope]'s dispatcher (main + * in production). A failed pull keeps the previous rows. + * + * [start] once when the screen first composes; [stop] on the route's + * release (idempotent; a stopped model never restarts). + */ +@Stable +class SideChatsModel( + private val parentChatId: String, + private val source: SideChatsSource, + private val hub: SessionEventHub, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + private val io: CoroutineDispatcher = Dispatchers.IO, + private val logW: (String, Throwable?) -> Unit = { _, _ -> }, +) { + private val trigger = Channel(Channel.CONFLATED) + private val _ui = MutableStateFlow(SideChatsUi()) + + /** The current sections; empty (badge hidden) until the first pull lands. */ + val ui: StateFlow = _ui.asStateFlow() + + private var started = false + private var stopped = false + private var unregister: (() -> Unit)? = null + + /** Register on the hub and pull once. Main thread; idempotent. */ + fun start() { + if (started || stopped) return + started = true + unregister = hub.register(parentChatId) { trigger.trySend(Unit) } + trigger.trySend(Unit) + scope.launch { + for (token in trigger) refresh() + } + } + + /** Another pull, coalesced with any pending one (e.g. right after a fork). */ + fun invalidate() { + if (!stopped) trigger.trySend(Unit) + } + + /** Unregister and stop pulling. Main thread; idempotent. */ + fun stop() { + if (stopped) return + stopped = true + unregister?.invoke() + unregister = null + trigger.close() + scope.cancel() + } + + private suspend fun refresh() { + val next = try { + withContext(io) { SideChatsUi.of(source.agents(), source.children(parentChatId)) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + logW("side chats refresh failed", t) + return + } + _ui.value = next + } +} + +/** + * Whether an engine serves side chats (`ForkSideChat`, `createChat` with + * `parentChatId`), from its reported version: they shipped in Roboco + * 0.5.0. An unknown or unparsable version reads as supported (a fork on + * an engine without the RPC then fails with the engine's own reason). + * Pure. + */ +fun sideChatsSupported(engineVersion: String?): Boolean { + val v = SemVer.parse(engineVersion) ?: return true + return v.major > 0L || v.minor >= 5L +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt new file mode 100644 index 0000000000..569013a1d4 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt @@ -0,0 +1,532 @@ +package sh.roboco.android.sidechats + +import android.content.ClipboardManager +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.roboco.android.RobocoApplication +import sh.roboco.android.core.Face +import sh.roboco.android.core.TextEngine +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.StatusGlyph +import sh.roboco.android.design.StatusGlyphKind +import sh.roboco.android.design.Toaster +import sh.roboco.android.design.glass.LocalHazeState +import sh.roboco.android.design.glass.Menu +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.ProvideHazeState +import sh.roboco.android.design.glass.rememberGlassMenuState +import sh.roboco.android.design.uiStyle +import sh.roboco.android.image.ImageViewerRequest +import sh.roboco.android.image.LocalImageViewer +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.session.SessionHeaderDefaults +import sh.roboco.android.session.copyTranscript +import sh.roboco.android.shell.LocalNavEntry +import sh.roboco.android.shell.LocalNavStack +import sh.roboco.android.shell.NavEntry +import sh.roboco.android.shell.NavRoute +import sh.roboco.android.shell.NavStack +import sh.roboco.android.transcript.AttachmentReader +import sh.roboco.android.transcript.TranscriptActions +import sh.roboco.android.transcript.TranscriptHost +import sh.roboco.android.transcript.TranscriptImageBinding +import sh.roboco.android.transcript.TranscriptImageLoader +import sh.roboco.android.transcript.TranscriptState +import sh.roboco.android.transcript.rememberLinkRouter +import sh.roboco.android.transcript.transcriptBottomInset +import sh.roboco.android.transcript.widgets.imageTileRadius +import uniffi.roboco_core.CoreClient +import uniffi.roboco_core.SessionHandle +import uniffi.roboco_core.TextSystem + +/** The viewer's copy, verbatim from upstream #899's `SubagentViewController`. */ +object SubagentCopy { + const val Subagent = "Subagent" + const val NothingYet = "Nothing from this subagent yet." + const val OpenFailed = "Couldn't open this subagent." +} + +/** + * The header subtitle: iOS's `"Subagent · "`, with the + * Roboco addition of the live state while the subagent is not done — + * `"Running · …"` while it works (a background subagent keeps running + * after its spawn call resolved) and `"Failed · …"` once it failed. Pure. + */ +fun subagentSubtitle(state: AgentState, parentTitle: String): String { + val word = when (state) { + AgentState.Running -> "Running" + AgentState.Done -> SubagentCopy.Subagent + AgentState.Failed -> "Failed" + } + return if (parentTitle.isBlank()) word else "$word · $parentTitle" +} + +/** + * A subagent's transcript, read-only (android-next #06, the port of + * upstream #899's `SubagentViewController`): the doc a spawn card links + * to. Roboco's engine serves that doc itself — the same + * `WatchDocMessages` stream desktop's subagent tab subscribes to + * (`crates/ui` `watch_subagent_doc`), never #899's edge blob — so the + * transcript is live while the subagent runs, including the claude-code + * `run_in_background` shape whose spawn call resolved long before the + * subagent finished, stops changing once it is done, and streams again if + * it resumes. No composer: the parent agent drives the subagent. + * + * [state] follows the spawn chip in the transcript that opened this + * viewer ([spawnerId]: the chat, or the subagent above for a nested + * spawn), re-read on that transcript's `SessionChanged` — the same chip + * the card's running dots read, so the two never disagree. + * + * Built off main by [buildSubagentRoute]; the transcript state is made on + * the first main-thread read. [start] runs on first composition and + * [release] once, through the entry's close safety net (a committed pop). + */ +@Stable +class SubagentRoute internal constructor( + /** The subagent's doc id (scoped): the read-only session's key. */ + val docId: String, + /** The spawn's description — the bar title. */ + val title: String, + /** The transcript whose spawn chip this is (its chat, or the subagent above). */ + val spawnerId: String, + /** The spawner's display title, for the subtitle. */ + val parentTitle: String, + /** The engine device that serves attachments (`composer.host.deviceId`). */ + val hostDeviceId: String, + private val client: CoreClient, + private val handle: SessionHandle, + private val hub: SessionEventHub, + /** The app-lifetime scope teardown and state reads ride on (never the entry's). */ + private val appScope: CoroutineScope, + private val text: TextSystem, + initialState: AgentState, + /** One serial lane per viewer: attach, the state reads and detach keep their order. */ + private val io: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1), +) : NavRoute { + + override val hidesBottomChrome: Boolean get() = true + + override val paneTitle: String get() = title + + /** The subagent's lifecycle, as its spawn chip says. Snapshot state (main). */ + var state: AgentState by mutableStateOf(initialState) + private set + + private var transcriptImpl: TranscriptState? = null + + /** The layout engine. MAIN THREAD ONLY: created on first read. */ + val transcript: TranscriptState + get() = transcriptImpl ?: TranscriptState(text).also { transcriptImpl = it } + + private var started = false + private var released = false + private var unregister: (() -> Unit)? = null + + /** + * First composition (main): bind the layout engine to the read-only + * session and mark it on screen — on the serial lane, attach first + * (iOS `setViewAttached` then `engine.attach`; the order is not + * observable, the frames come from the same handle) — then follow the + * spawn chip's state. + */ + internal fun start() { + if (started || released) return + started = true + val view = transcript.engine + appScope.launch(io) { + try { + if (!view.attach(client, docId)) Log.w(TAG, "subagent transcript attach returned false") + handle.setViewAttached(true) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent attach failed", t) + } + } + unregister = hub.register(spawnerId) { refreshState() } + refreshState() + } + + /** Re-read the spawn chip off main; publish on main. */ + private fun refreshState() { + if (released) return + appScope.launch(io) { + val next = try { + // A fresh wrapper per read: close it here (the core keeps the + // session itself warm), never leave it to the GC. + client.session(spawnerId)?.let { spawner -> + try { + spawner.subagents().firstOrNull { it.docId == docId }?.let { agentState(it.state) } + } finally { + spawner.close() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent state read failed", t) + null + } ?: return@launch + withContext(Dispatchers.Main) { + if (!released) state = next + } + } + } + + /** + * The committed pop (the entry's close safety net): stop following the + * chip, close the layout engine (main), then detach and close the + * session on the lane — the doc stays warm in the core for a re-open. + * Idempotent. + */ + fun release() { + if (released) return + released = true + unregister?.invoke() + unregister = null + transcriptImpl?.close() + appScope.launch(io) { + try { + handle.setViewAttached(false) + client.closeSession(docId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent release failed", t) + } + } + } + + /** The attachment reader for the transcript's images (the host device's files). */ + internal val attachments: AttachmentReader = object : AttachmentReader { + override suspend fun read(engineDeviceId: String, reference: String): ByteArray = + client.readAttachment(engineDeviceId, reference) + } + + @Composable + fun Content() { + val entry = LocalNavEntry.current + // The close safety net (the NavEntry contract): any removal path + // releases exactly once. + remember(entry) { + entry.retain(CloseKey) { + object : AutoCloseable { + override fun close() = release() + } + } + } + remember(entry) { + start() + true + } + SubagentScreen(this) + } + + private object CloseKey + + private companion object { + const val TAG = "RobocoSubagent" + } +} + +/** What a spawn card (or a sheet row) asks to open. */ +data class SubagentOpenRequest( + val docId: String, + val title: String, + /** The card said it had finished (done or failed). */ + val frozen: Boolean, + /** The transcript the card sits in: the chat, or the subagent above. */ + val spawnerId: String, + val parentTitle: String, +) + +/** + * Off main: open the subagent's doc read-only (`CoreClient.openSubagent` — + * the cache seed and the subscription start, no round trip) and build its + * route. `null` when there is no client or the core refused the id. + */ +suspend fun buildSubagentRoute( + app: RobocoApplication, + request: SubagentOpenRequest, + io: CoroutineDispatcher = Dispatchers.IO, +): SubagentRoute? = withContext(io) { + try { + val client = app.clientOrNull() ?: return@withContext null + val handle = client.openSubagent(request.docId) + val host = try { + handle.composer().host.deviceId + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + "" + } + SubagentRoute( + docId = request.docId, + title = request.title, + spawnerId = request.spawnerId, + parentTitle = request.parentTitle, + hostDeviceId = host, + client = client, + handle = handle, + hub = app.coreStartup.sessionEvents, + appScope = app.appScope, + text = TextEngine.shared, + initialState = if (request.frozen) AgentState.Done else AgentState.Running, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w("RobocoSubagent", "open subagent failed", t) + null + } +} + +/** + * Opens subagent viewers over one screen (the session, or a viewer for a + * nested spawn): the doc opens off main, then the route is pushed — only + * if this screen is still on top and idle (a late open never pushes over + * a screen the user already left, the `leaveAfterArchive` rule). One open + * at a time; a failed open toasts "Couldn't open this subagent." + */ +class SubagentOpener( + private val app: RobocoApplication, + private val nav: NavStack, + private val entry: NavEntry, + private val scope: CoroutineScope, + private val toaster: Toaster, +) { + private var opening = false + + /** Main thread. */ + fun open(request: SubagentOpenRequest) { + if (opening) return + opening = true + scope.launch { + try { + val route = buildSubagentRoute(app, request) + when { + route == null -> toaster.show(SubagentCopy.OpenFailed) + nav.top !== entry || nav.transition != null -> route.release() + else -> nav.push(route, animated = true) + } + } finally { + opening = false + } + } + } +} + +/** + * The opener for the screen composing this (the [LocalNavStack] / + * [LocalNavEntry] it lives in); `null` outside the app (previews, tests). + */ +@Composable +fun rememberSubagentOpener(): SubagentOpener? { + val app = LocalContext.current.applicationContext as? RobocoApplication ?: return null + val nav = LocalNavStack.current + val entry = LocalNavEntry.current + val toaster = LocalToaster.current + val scope = rememberCoroutineScope() + return remember(app, nav, entry, toaster, scope) { SubagentOpener(app, nav, entry, scope, toaster) } +} + +/** The ⋯ menu: one row, Copy Transcript (`SubagentViewController`). Pure. */ +fun subagentMenu(onCopyTranscript: () -> Unit): Menu = Menu( + title = null, + children = listOf( + MenuItem( + title = "Copy Transcript", + icon = MenuIcon.Symbol(RobocoIcons.DocOnDoc), + testTag = "subagent-menu-copy", + action = onCopyTranscript, + ), + ), +) + +/** + * The viewer screen: the read-only transcript under the session header + * (back, the spawn's title over [subagentSubtitle], a ⋯ with Copy + * Transcript), with iOS's reveal — the spinner until the first row lands, + * then the transcript; "Nothing from this subagent yet." after 8 s + * without one. Nested spawn cards open their own viewer on top. + */ +@Composable +internal fun SubagentScreen(route: SubagentRoute, modifier: Modifier = Modifier) { + val context = LocalContext.current + val palette = RobocoTheme.palette + val toaster = LocalToaster.current + val transcript = route.transcript + val scope = rememberCoroutineScope() + val menuState = rememberGlassMenuState() + val opener = rememberSubagentOpener() + + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + SideEffect { transcript.topInset = SessionHeaderDefaults.contentTopInset(statusBarTop).value } + val edgeEngaged by remember(transcript) { derivedStateOf { transcript.viewport.scrollY > 0.5f } } + val revealed by remember(transcript) { derivedStateOf { (transcript.frame?.rowCount() ?: 0u) > 0u } } + var gaveUp by remember(route) { mutableStateOf(false) } + LaunchedEffect(route) { + delay(GiveUpMs) + gaveUp = true + } + + // The transcript's actions — SessionScreen's image and link wiring + // over this doc's host device, plus nested subagent cards. + val viewer = LocalImageViewer.current + val density = LocalDensity.current + val binding = remember(route) { + TranscriptImageBinding(TranscriptImageLoader.get(context), { route.hostDeviceId }, route.attachments) + } + val router = rememberLinkRouter( + loadFull = { reference -> binding.loadFull(reference) }, + showImage = { reference, source, bitmap -> + viewer?.show( + ImageViewerRequest( + reference = reference, + preview = bitmap, + sourceBounds = source, + sourceCornerRadius = 0.dp, + label = null, + loadFull = null, + ), + ) + }, + ) + val actions = remember(route, viewer, router, opener) { + val openImage: (String, Rect, ImageBitmap?, String?) -> Unit = { reference, source, bitmap, label -> + viewer?.show( + ImageViewerRequest( + reference = reference, + preview = bitmap, + sourceBounds = source, + sourceCornerRadius = with(density) { + imageTileRadius(source.width.toDp().value, source.height.toDp().value) + }, + label = label, + loadFull = { binding.loadFull(reference) }, + ), + ) + } + TranscriptActions( + openLink = router::open, + openImage = openImage, + loadImage = binding::load, + cachedImage = binding::cached, + openSubagent = { docId, title, frozen -> + opener?.open( + SubagentOpenRequest( + docId = docId, + title = title, + frozen = frozen, + spawnerId = route.docId, + parentTitle = route.title, + ), + ) + }, + ) + } + + Box( + modifier + .fillMaxSize() + .background(palette.background) + .testTag("subagent-viewer"), + ) { + ProvideHazeState { + val haze = LocalHazeState.current + // The transcript is always composed (rows lay out and the tail + // is placed while hidden); it shows with its first row. + Box( + Modifier + .fillMaxSize() + .then(if (haze != null) Modifier.hazeSource(haze) else Modifier) + .graphicsLayer { alpha = if (revealed) 1f else 0f }, + ) { + TranscriptHost( + transcript, + actions, + Modifier + .fillMaxSize() + .transcriptBottomInset(transcript.viewport, navBarBottom) + .testTag("subagent-transcript"), + ) + } + if (!revealed) { + if (!gaveUp) { + StatusGlyph( + StatusGlyphKind.Spinner, + Modifier.align(Alignment.Center).testTag("subagent-loading"), + scale = 1.75f, + ) + } else { + BasicText( + SubagentCopy.NothingYet, + Modifier.align(Alignment.Center).testTag("subagent-empty"), + style = uiStyle(Face.Sans, LocalTypeScale.current.fixed(15f), palette.secondary), + ) + } + } + SessionHeader( + title = route.title, + subtitle = subagentSubtitle(route.state, route.parentTitle), + edgeEngaged = edgeEngaged, + onMore = { anchor -> + menuState.open( + anchor, + subagentMenu(onCopyTranscript = { + scope.launch { + val clipboard = context.applicationContext + .getSystemService(ClipboardManager::class.java) ?: return@launch + copyTranscript(transcript, clipboard, toaster) + } + }), + ) + }, + statusBarTop = statusBarTop, + ) + } + } +} + +/** iOS `asyncAfter(deadline: .now() + 8)`: the empty state replaces the spinner. */ +private const val GiveUpMs = 8_000L diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt index 3de7310d85..4f6f41b5b1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt @@ -183,14 +183,15 @@ fun rememberRowMenuPerformer(actions: () -> TranscriptActions): RowMenuPerformer /** * The widget kinds that own their touches (`ios:Transcript/RowView.swift` * — `CopyCode` `:202-204`, `Disclosure` `:205-211`, `Image` `:214-234`, - * `Detail` `:239-249`, `ToolToggle` `:268-277`). Everything else + * `Detail` `:239-249`, `ToolToggle` `:268-277`, and upstream #899's + * `SubagentLink`, a `ToolToggleControl` over a spawn card). Everything else * (`Icon`, `ToolStatus`, `Spinner`, `Working`, `Chevron`, `ToolRail`, * `Shimmer`) is display-only (`isUserInteractionEnabled = false`) and a * long press over it lifts the row. */ internal fun WidgetKind.isControl(): Boolean = this is WidgetKind.CopyCode || this is WidgetKind.Disclosure || this is WidgetKind.ToolToggle || - this is WidgetKind.Detail || this is WidgetKind.Image + this is WidgetKind.Detail || this is WidgetKind.Image || this is WidgetKind.SubagentLink /** * True when a touch-down at row-local `(xDp, yDp)` lands on a control diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt index df8106716b..a87cf2b457 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt @@ -11,6 +11,7 @@ import sh.roboco.android.transcript.widgets.ImageTileCopy import sh.roboco.android.transcript.widgets.RailSpec import sh.roboco.android.transcript.widgets.ShimmerWidget import sh.roboco.android.transcript.widgets.SpinnerWidget +import sh.roboco.android.transcript.widgets.SubagentLinkTarget import sh.roboco.android.transcript.widgets.ToolChevron import sh.roboco.android.transcript.widgets.ToolRail import sh.roboco.android.transcript.widgets.ToolStatusWidget @@ -54,6 +55,13 @@ internal fun RowWidget(ctx: RowContext, w: Widget) { onToggle = { ctx.state.toggleDetail(ctx.model.display.key, k.detail, k.open) }, ) is WidgetKind.Chevron -> ToolChevron(rowKey = ctx.model.display.key, expanded = k.expanded) + // android-next #06 (upstream #899's `subagentLink`): the whole spawn + // card opens the subagent's transcript; the actions are read at + // click time so a rebuilt TranscriptActions is honoured. + is WidgetKind.SubagentLink -> SubagentLinkTarget( + title = k.title, + onOpen = { ctx.actions().openSubagent(k.docId, k.title, k.frozen) }, + ) // ticket 123 (status glyphs): running beats failed; the Working / // Writing flip reuses this slot (same `#working` row key), so the // ticker's remember survives and the clock never restarts — no diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt index d1dfd6468c..cc38db7be3 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt @@ -71,4 +71,11 @@ class TranscriptActions( * Initial-pass observer. */ val onTap: () -> Unit = {}, + /** + * A subagent spawn card was tapped (android-next #06, upstream #899's + * `onOpenSubagent`): open the subagent's read-only transcript. + * [docId] is the scoped subagent doc, [title] the spawn's description, + * [frozen] whether the subagent had already finished when tapped. + */ + val openSubagent: (docId: String, title: String, frozen: Boolean) -> Unit = { _, _, _ -> }, ) diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt index 0cfb456c51..2b8a4bce4e 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt @@ -78,7 +78,8 @@ fun Modifier.transcriptControl(): Modifier = this then TranscriptControlElement */ val WidgetKind.isTranscriptControl: Boolean get() = when (this) { - WidgetKind.CopyCode, is WidgetKind.Disclosure, is WidgetKind.ToolToggle, is WidgetKind.Detail -> true + WidgetKind.CopyCode, is WidgetKind.Disclosure, is WidgetKind.ToolToggle, is WidgetKind.Detail, + is WidgetKind.SubagentLink -> true is WidgetKind.Image, is WidgetKind.Chevron, is WidgetKind.Icon, is WidgetKind.ToolStatus, WidgetKind.Spinner, is WidgetKind.Working, is WidgetKind.ToolRail, WidgetKind.Shimmer -> false } diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt new file mode 100644 index 0000000000..c7fa0b9bf2 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt @@ -0,0 +1,59 @@ +package sh.roboco.android.transcript.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import sh.roboco.android.design.Haptic +import sh.roboco.android.design.LocalFeedback +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.rememberSmoothRoundedShape + +/** + * `WidgetKind.SubagentLink`'s view (android-next #06; upstream #899's + * `RowView` case — a `ToolToggleControl` with a 10 pt continuous corner): + * an invisible tap target over a whole subagent spawn card. The Rust rect + * is the card itself, so the card's icon, label, description and running + * dots stay Rust paint underneath and the dots keep following the chip's + * `subagent_status`. + * + * While the finger is down the card fills with `controlFill`, rounded 10 dp + * and instant both ways, like [ToolToggleTarget]. A tap plays the `Select` + * haptic (iOS `UISelectionFeedbackGenerator`) then [onOpen]. The label is + * iOS's "Open subagent: ". + */ +@Composable +fun SubagentLinkTarget(title: String, onOpen: () -> Unit, modifier: Modifier = Modifier) { + val feedback = LocalFeedback.current + val interactions = remember { MutableInteractionSource() } + val pressed = interactions.collectIsPressedAsState() + val fill = RobocoTheme.palette.controlFill + val shape = rememberSmoothRoundedShape(10.dp) + Box( + modifier + .fillMaxSize() + .testTag("subagent-link") + // The State is read inside the draw block: a press redraws, + // never recomposes. + .drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + onDrawBehind { if (pressed.value) drawOutline(outline, fill) } + } + .clickable(interactionSource = interactions, indication = null, role = Role.Button) { + feedback.haptic(Haptic.Select) + onOpen() + } + .semantics { contentDescription = "Open subagent: $title" }, + ) +} From a19ba3d0133aed2968154601d7b088f9dfbbd68d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Thu, 15 Oct 2026 21:19:38 +0700 Subject: [PATCH 07/13] feat(android): Side Chats & Agents sheet, header button, New Side Chat and Fork (android-next#06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's second bar item (badge: '● N' while subagents run, else a quiet count) opens the sheet: Running, Side chats, Finished agents, foldable. The session menu gains New Side Chat (the New Session canvas pushed in side mode: model and effort only, nothing written until the first send, parentChatId on create) and Fork into Side Chat (ForkSideChat, one at a time). Engines older than 0.5.0 get no side-chat entry points. Co-Authored-By: Claude <noreply@anthropic.com> --- .../android/newsession/NewSessionCanvas.kt | 17 +- .../android/newsession/NewSessionChips.kt | 8 +- .../android/newsession/NewSessionCreate.kt | 36 +- .../android/newsession/NewSessionModel.kt | 97 +++- .../android/newsession/NewSessionScreen.kt | 32 ++ .../android/newsession/NewSessionSend.kt | 17 +- .../session/CoreSessionRouteFactory.kt | 14 + .../android/session/SessionController.kt | 21 + .../roboco/android/session/SessionHeader.kt | 29 +- .../android/session/SessionHeaderMetrics.kt | 10 +- .../sh/roboco/android/session/SessionMenu.kt | 44 +- .../android/session/SessionScreenRoute.kt | 121 ++++- .../sh/roboco/android/shell/ShellRoutes.kt | 8 + .../roboco/android/sidechats/SideChatDraft.kt | 103 ++++ .../roboco/android/sidechats/SideChatFork.kt | 77 +++ .../android/sidechats/SideChatsButton.kt | 72 +++ .../android/sidechats/SideChatsSheet.kt | 494 ++++++++++++++++++ 17 files changed, 1157 insertions(+), 43 deletions(-) create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt create mode 100644 apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt index fc872101e6..6cfa51bfc1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt @@ -145,6 +145,10 @@ internal fun NewSessionCanvas( state: NewSessionCanvasState = remember { NewSessionCanvasState() }, insets: CanvasInsets? = null, wallpaper: @Composable (Modifier, () -> Rect?) -> Unit = { m, c -> WallpaperHero(m, cutout = c) }, + /** android-next #06: a side chat's draft says "Ask on the side". */ + headline: String = NewSessionHeadline, + /** android-next #06: false when the host draws its own bar (the side-chat draft's session header). */ + navBar: Boolean = true, ) { ProvideHazeState { val haze = LocalHazeState.current!! @@ -191,6 +195,7 @@ internal fun NewSessionCanvas( Modifier .hazeSource(haze, zIndex = 1f) .testTag("new-session-hero"), + headline = headline, ) } val composerSlot: @Composable () -> Unit = { @@ -323,7 +328,7 @@ internal fun NewSessionCanvas( }, ) } - NewSessionNavBar(embedded, onClose, insets) + if (navBar) NewSessionNavBar(embedded, onClose, insets) } } } @@ -337,7 +342,7 @@ internal fun NewSessionCanvas( * the column is 76.6 dp (1 line) or 105.2 dp (2 lines) at font scale 1. */ @Composable -internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { +internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier, headline: String = NewSessionHeadline) { val palette = RobocoTheme.palette val typeScale = LocalTypeScale.current Column( @@ -354,7 +359,7 @@ internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { .clearAndSetSemantics { }, ) BasicText( - "What are we building?", + headline, Modifier.testTag("new-session-headline"), style = uiStyle(Face.SansSemibold, typeScale.fixed(NewSessionLayout.HeadlineSize), palette.text) .copy(textAlign = TextAlign.Center), @@ -364,6 +369,12 @@ internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { } } +/** The New Session hero's headline (`:122`). */ +const val NewSessionHeadline = "What are we building?" + +/** A side chat's draft headline (upstream #899's `sideOf` hero). */ +const val SideChatHeadline = "Ask on the side" + /** * The transparent nav bar (`:113-118`, `SplitRootController.swift:46-47`). * diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt index b6e27d5d98..d07bad2ae1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt @@ -171,6 +171,9 @@ fun newSessionChips( return chips } +/** The chips a side chat's draft keeps (iOS `refreshChips` with `sideOf`). */ +private val SideChatChipIds = setOf(NewSessionChipIds.Model, NewSessionChipIds.Effort) + /** * Foundation's `String.capitalized` (`CFStringCapitalize`), which the effort * titles (here and in ticket 114's effort rows) use: a letter is @@ -262,13 +265,16 @@ fun NewSessionChipsEffect( ) { val chips by remember(model, labels) { derivedStateOf { - newSessionChips( + val all = newSessionChips( model.draft, model.project, model.currentOptions?.hosts.orEmpty(), model.models, labels, ) + // android-next #06: a side chat runs in its parent's checkout + // on its parent's host — only the model and effort are picked. + if (model.side == null) all else all.filter { it.id in SideChatChipIds } } } // Seed synchronously so the first composition already has the chips. diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt index 47ff3eb636..17dab3139d 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt @@ -77,7 +77,10 @@ internal object NewSessionRequests { * neither → `null`, and the caller fails **without** a core call (the * alert explains). Ids are the scoped ids the options carry. */ - fun target(d: NewSessionDraft): SessionTarget? { + fun target(d: NewSessionDraft, side: SideChatParent? = null): SessionTarget? { + // android-next #06: ignored by the core for a side chat (the + // parent's host fills it), but the record needs one (iOS `sideOf`). + if (side != null) return SessionTarget.Projectless(deviceId = side.deviceId) val projectId = d.projectId if (projectId != null) return SessionTarget.Project(spaceId = projectId) val hostId = d.hostId @@ -89,9 +92,11 @@ internal object NewSessionRequests { * `NewSession(target:config:branch:cwd:title:)` (`:629-630`): * `reasoning = effort` (nil ⇒ the engine chooses), `sandbox` is * always workspace-write on this canvas, `branch` only without a - * worktree (with one, the branch becomes the worktree's base). + * worktree (with one, the branch becomes the worktree's base). A side + * chat (android-next #06) names its parent and no branch: it runs in + * the parent's checkout (the core keeps the parent's sandbox too). */ - fun newSession(d: NewSessionDraft, target: SessionTarget): NewSession = NewSession( + fun newSession(d: NewSessionDraft, target: SessionTarget, side: SideChatParent? = null): NewSession = NewSession( target = target, config = ChatConfig( harness = d.harness, @@ -100,9 +105,10 @@ internal object NewSessionRequests { modelOptions = emptyMap(), sandbox = SandboxLevel.WORKSPACE_WRITE, ), - branch = if (d.worktree) null else d.branch, + branch = if (d.worktree || side != null) null else d.branch, cwd = null, title = null, + parentChatId = side?.chatId, ) /** @@ -117,6 +123,7 @@ internal object NewSessionRequests { text: String, images: List<StagedImage>, projects: List<ProjectOption>, + side: SideChatParent? = null, ): SendRequest = SendRequest( text = text, attachments = images.map { image -> @@ -127,7 +134,8 @@ internal object NewSessionRequests { data = image.bytes, ) }, - worktree = if (d.worktree) { + // A side chat never makes a worktree (iOS `draft.worktree && parentId == nil`). + worktree = if (d.worktree && side == null) { projects.firstOrNull { it.id == d.projectId }?.let { project -> WorktreeSpec( repoPath = project.path, @@ -170,14 +178,15 @@ class NewSessionCreator( text: String, images: List<StagedImage>, projects: List<ProjectOption>, + side: SideChatParent? = null, ): String? { // No target: fail without a core call and without a log — the // alert explains it, and there is no error to log. - val target = NewSessionRequests.target(d) ?: return null + val target = NewSessionRequests.target(d, side) ?: return null return withContext(io) { try { - val chatId = core.createSession(NewSessionRequests.newSession(d, target)) - core.sendFirst(chatId, NewSessionRequests.sendRequest(d, text, images, projects)) + val chatId = core.createSession(NewSessionRequests.newSession(d, target, side)) + core.sendFirst(chatId, NewSessionRequests.sendRequest(d, text, images, projects, side)) chatId } catch (cancelled: CancellationException) { throw cancelled @@ -201,6 +210,17 @@ val StartFailedAlert = RobocoAlert( buttons = listOf(AlertButton("OK", AlertButtonRole.Default)), ) +/** + * A side chat's failure alert (android-next #06, upstream #899's + * `create(sideOf:)` copy verbatim): the parent is gone or the create + * failed — the composer stays `Kept`. + */ +val SideChatStartFailedAlert = RobocoAlert( + title = "Couldn't start the side chat", + message = "Its session is no longer available.", + buttons = listOf(AlertButton("OK", AlertButtonRole.Default)), +) + /** * The canvas composer's offline gate (94's Roboco ADD, the parent * spec's "Send is disabled while the target engine is offline"): the diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt index 06b723769e..d50d867e48 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt @@ -2,6 +2,7 @@ package sh.roboco.android.newsession import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -24,9 +25,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.roboco.android.RobocoApplication import sh.roboco.android.composer.ComposerController +import sh.roboco.android.composer.DEFAULT_HARNESS import sh.roboco.android.composer.DraftKey import sh.roboco.android.composer.LocalDrafts import sh.roboco.android.composer.StagedImage +import uniffi.roboco_core.SessionRow /** * The New Session page's state holder (android-ui-parity #92) — the port @@ -69,13 +72,21 @@ class NewSessionModel( private val appScope: CoroutineScope, private val io: CoroutineDispatcher = Dispatchers.IO, private val main: CoroutineDispatcher = Dispatchers.Main.immediate, + /** + * android-next #06: a side chat's draft (upstream #899's + * `NewSessionViewController(sideOf:)`). It runs in the parent's + * checkout on the parent's host, so only the model and effort are + * picked (any harness, until the first send mints the row); it starts + * on the parent's model and never reads or writes the sticky store. + */ + val side: SideChatParent? = null, ) { /** The epoch the page opened with; a full reset while open freezes this page. */ private val openedEpoch: Int = store.resetEpoch - /** The page's copy of the sticky draft (iOS `self.draft`, `:75`). */ - var draft: NewSessionDraft by mutableStateOf(store.lastDraft) + /** The page's copy of the sticky draft (iOS `self.draft`, `:75`); a side chat's starts on its parent's model. */ + var draft: NewSessionDraft by mutableStateOf(side?.let(::sideChatDraft) ?: store.lastDraft) private set /** Models for the draft's device: cached or built-in at once, then the engine's own. */ @@ -83,10 +94,10 @@ class NewSessionModel( private set /** `prompt ?: store.text` — a non-null prompt (even `""`) wins over the saved text (`:78`). */ - val initialText: String = prompt ?: store.text + val initialText: String = prompt ?: if (side == null) store.text else "" /** The images staged when the page last closed unsent (`:79`). */ - val initialImages: List<StagedImage> = store.images + val initialImages: List<StagedImage> = if (side == null) store.images else emptyList() /** Snapshot state; assigned by the options collector (109/113 read it in `derivedStateOf`). */ var currentOptions: NewSessionOptions? by mutableStateOf(options.value) @@ -111,9 +122,10 @@ class NewSessionModel( init { // The defaults run once, here (iOS `viewDidLoad` `:177-181`) — - // without a coroutine, so the first frame already has them. + // without a coroutine, so the first frame already has them. A side + // chat picks no project or host (`sideOf == nil` guards on iOS). val initial = options.value - if (initial != null) { + if (initial != null && side == null) { val n = normalize(draft, initial) if (n != draft) updateInternal({ n }, userEdit = false) } @@ -123,7 +135,7 @@ class NewSessionModel( pageScope.launch { options.collect { o -> currentOptions = o - if (o != null) { + if (o != null && side == null) { val n = normalize(draft, o) if (n != draft) updateInternal({ n }, userEdit = false) else loadModels() } @@ -132,8 +144,9 @@ class NewSessionModel( // The late store load (the tablet launch page, 106, and a // `roboco://new` cold start can open the page before `warm()` // finished): once it lands, an untouched page takes over the - // stored draft; a touched one keeps the user's pick. - if (!store.isLoaded) { + // stored draft; a touched one keeps the user's pick. A side chat's + // draft never comes from the store. + if (!store.isLoaded && side == null) { pageScope.launch { store.awaitLoaded() if (!touched) { @@ -148,13 +161,13 @@ class NewSessionModel( } } - /** The picked project (iOS `:236`); null while the options are still null. */ + /** The picked project (iOS `:236`); null while the options are still null, and for a side chat. */ val project: ProjectOption? - get() = currentOptions?.projects?.firstOrNull { it.id == draft.projectId } + get() = if (side != null) null else currentOptions?.projects?.firstOrNull { it.id == draft.projectId } - /** The engine the session runs on (iOS `:237`). */ + /** The engine the session runs on (iOS `:237`); a side chat's parent's host. */ val deviceId: String - get() = project?.device ?: draft.hostId ?: "" + get() = side?.deviceId ?: project?.device ?: draft.hostId ?: "" /** * Checkouts of the picked project, one per row of the host menu @@ -194,7 +207,8 @@ class NewSessionModel( * reset while the page was open (epoch mismatch). */ fun rememberDraft(text: String, images: List<StagedImage>) { - if (created || store.resetEpoch != openedEpoch) return + // A side chat's draft is its own: it never seeds the next new session. + if (created || side != null || store.resetEpoch != openedEpoch) return if (store.isLoaded || touched) store.lastDraft = draft store.text = text store.images = images @@ -206,7 +220,7 @@ class NewSessionModel( * draft, unconditionally. */ fun persistBeforeCreate() { - store.lastDraft = draft + if (side == null) store.lastDraft = draft } /** @@ -216,6 +230,7 @@ class NewSessionModel( */ fun markCreated() { created = true + if (side != null) return // the sticky text and images were never this page's store.text = "" store.images = emptyList() } @@ -268,11 +283,52 @@ class NewSessionModel( if (n == draft) return draft = n if (userEdit) touched = true - if (!created && store.isLoaded && store.resetEpoch == openedEpoch) store.lastDraft = n + if (!created && side == null && store.isLoaded && store.resetEpoch == openedEpoch) store.lastDraft = n loadModels() } } +/** + * The chat a side-chat draft hangs off (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`), read from its workspace row when + * the page opens. The create sends `parentChatId` and the core takes the + * host, the checkout and the sandbox from the parent; this record only + * names the parent and seeds the page. + */ +@Immutable +data class SideChatParent( + /** The scoped chat id the new side chat hangs off (a side chat's own: a sibling, the core decides). */ + val chatId: String, + /** The parent's title — the nav subtitle "Side chat · <title>". */ + val title: String, + /** The parent's host device: the model catalog's device. */ + val deviceId: String, + /** The parent's project (scoped space id), for `@file` search; null when projectless. */ + val projectId: String?, + val harness: String?, + val model: String?, + val effort: String?, +) { + companion object { + fun of(row: SessionRow): SideChatParent = SideChatParent( + chatId = row.id, + title = row.title, + deviceId = row.deviceId, + projectId = row.project?.id, + harness = row.harness, + model = row.model, + effort = row.reasoning, + ) + } +} + +/** A side chat's starting draft: the parent's harness, model and effort (iOS `init(sideOf:)`). Pure. */ +fun sideChatDraft(parent: SideChatParent): NewSessionDraft = NewSessionDraft( + harness = parent.harness?.takeIf { it.isNotEmpty() } ?: DEFAULT_HARNESS, + model = parent.model, + effort = parent.effort, +) + /** * The stale-id rule and the defaults (ticket 92's Normalize table, the * iOS `viewDidLoad` defaults `:177-181` plus the Roboco stale rule), in @@ -312,10 +368,10 @@ fun normalize(d: NewSessionDraft, o: NewSessionOptions): NewSessionDraft { * composition; the fetches outlive it on the app scope. */ @Composable -fun rememberNewSessionModel(prompt: String?): NewSessionModel { +fun rememberNewSessionModel(prompt: String?, side: SideChatParent? = null): NewSessionModel { val app = LocalContext.current.applicationContext as RobocoApplication val pageScope = rememberCoroutineScope() - val model = remember(prompt) { + val model = remember(prompt, side) { val feed = app.workspaceFeed NewSessionModel( prompt = prompt, @@ -330,6 +386,7 @@ fun rememberNewSessionModel(prompt: String?): NewSessionModel { catalog = CoreModelCatalogSource { app.clientOrNull() }, pageScope = pageScope, appScope = app.appScope, + side = side, ) } DisposableEffect(model) { @@ -362,7 +419,9 @@ fun rememberNewSessionModel(prompt: String?): NewSessionModel { @Composable fun NewSessionDraftEffect(model: NewSessionModel, composer: ComposerController) { val drafts = LocalDrafts.current - if (remember { !drafts.isLoaded }) { + val loadPending = remember { !drafts.isLoaded } + // android-next #06: a side chat's composer never takes the new-session text. + if (loadPending && model.side == null) { LaunchedEffect(Unit) { val t = drafts.load(DraftKey.NewSession) if (composer.text.isEmpty() && model.initialText.isEmpty()) composer.text = t diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt index 039de1bc32..b3ca254ac0 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt @@ -31,6 +31,7 @@ import sh.roboco.android.notify.NotificationPermission import sh.roboco.android.notify.findActivity import sh.roboco.android.shell.DraftHandoff import sh.roboco.android.shell.LocalPageSheetScope +import uniffi.roboco_core.FileMatch /** * The New Session page (android-ui-parity #93) — the port of @@ -87,6 +88,9 @@ internal fun NewSessionScreenBody( // The chip title chain's labels: the core's catalog in the app; tests and // previews pass GalleryLabels to stay FFI-free (FakeCatalog's intent). labels: ChipLabels = CoreChipLabels, + // android-next #06: the side-chat draft's hero line and its own bar. + headline: String = NewSessionHeadline, + navBar: Boolean = true, ) { val composer = rememberComposerController(initialText = ns.initialText) @@ -144,6 +148,9 @@ internal fun NewSessionScreenBody( // Runs once, before the first frame (iOS sets these in `init` and // `viewDidLoad`). bindNewSessionComposer(composer, project = { ns.project }, ns.initialImages, fileSearch) + // android-next #06: a side chat searches its parent's project (iOS + // `sideOf`: the parent's device and space; none when projectless). + ns.side?.let { side -> composer.mentionSearch = sideChatMentionSearch(side, fileSearch) } // 94: the real send handler replaces the `Kept` stub the bind // installs for direct-bind tests. composer.onSend = { text, images, _ -> handler.send(text, images) } @@ -207,6 +214,8 @@ internal fun NewSessionScreenBody( // outside). Chained after the caller's modifier so the recording // covers everything the sheet-sized node draws. modifier = modifier.handoffScene(capture), + headline = headline, + navBar = navBar, ) } @@ -250,6 +259,29 @@ internal fun bindNewSessionComposer( if (composer.onSend == null) composer.onSend = { _, _, _ -> SendResult.Kept } } +/** + * A side chat's `@` search (android-next #06, iOS `mentionSearch` with + * `sideOf`): the parent's project on the parent's device, `[]` for a + * projectless parent; errors read as no matches, cancellation propagates. + */ +internal fun sideChatMentionSearch( + side: SideChatParent, + fileSearch: NewSessionFileSearch, +): suspend (String) -> List<FileMatch> = { q -> + val space = side.projectId + if (space == null) { + emptyList() + } else { + try { + fileSearch.search(side.deviceId, space, q) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } + } +} + /** * Focus on appear (`viewDidAppear`, `:215-217`): the sheet waits for the * present motion to settle (Compose has no appear callback tied to the diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt index 73530485a9..b1967b773c 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt @@ -30,6 +30,9 @@ interface NewSessionPage { * `NewSessionViewController.swift:415-418`. */ fun markCreated() + + /** android-next #06: the chat a side-chat draft hangs off; `null` for a new session. */ + val side: SideChatParent? get() = null } /** The live [NewSessionPage] over ticket 92's model. */ @@ -38,6 +41,7 @@ class ModelNewSessionPage(private val m: NewSessionModel) : NewSessionPage { override val projects: List<ProjectOption> get() = m.currentOptions?.projects ?: emptyList() override fun persistBeforeCreate() = m.persistBeforeCreate() override fun markCreated() = m.markCreated() + override val side: SideChatParent? get() = m.side } /** @@ -82,14 +86,16 @@ class NewSessionSendHandler( // persist the options (sticky even on failure, iOS `:409`). val d = page.draft val projects = page.projects + val side = page.side page.persistBeforeCreate() - // 2. create → open → send on IO; null = failure. - val chatId = creator.createAndSend(d, text, images, projects) + // 2. create → open → send on IO; null = failure. A side chat + // (android-next #06) names its parent. + val chatId = creator.createAndSend(d, text, images, projects, side) // 3a. Failure: the alert, and the composer keeps everything. if (chatId == null) { - alerts.show(StartFailedAlert) + alerts.show(if (side != null) SideChatStartFailedAlert else StartFailedAlert) return@withContext SendResult.Kept } @@ -98,8 +104,9 @@ class NewSessionSendHandler( // 4. The one-time notification ask, fire-and-forget: it must // never throw into the sequence (the system dialog may open - // over the handoff, as on iOS — do not delay it). - runCatching { askAfterFirstSession() } + // over the handoff, as on iOS — do not delay it). A side chat + // is never a first session (iOS's side path asks nothing). + if (side == null) runCatching { askAfterFirstSession() } // 5. Capture before the composer clears (54 clears only after // this returns Sent; null when the page left composition). diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt b/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt index 008815d540..db7152d559 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt @@ -9,6 +9,8 @@ import sh.roboco.android.composer.QueueActions import sh.roboco.android.core.TextEngine import sh.roboco.android.shell.RouterHooks import sh.roboco.android.shell.SessionRouteFactory +import sh.roboco.android.sidechats.CoreSideChatsSource +import sh.roboco.android.sidechats.SideChatsModel import sh.roboco.android.transcript.TranscriptState import uniffi.roboco_core.TextSystem @@ -98,12 +100,24 @@ class CoreSessionRouteFactory( val queueActions = (core as? CoreSessionCore)?.let { live -> QueueActions(CoreQueuePort(live.handle), queueScope) } + // android-next #06: the chat's side chats and subagents over the + // same handle (its spawn chips) and the workspace children; the + // screen starts it on first composition, the release stops it. + val sideChats = (core as? CoreSessionCore)?.let { live -> + SideChatsModel( + parentChatId = live.chatId, + source = CoreSideChatsSource({ live.client }, live.handle), + hub = hub, + logW = logW, + ) + } return SessionScreenRoute( controller = controller, core = core, state = SessionScreenState(core.chatId, openedAt) { TranscriptState(ts) }, text = ts, queueActions = queueActions, + sideChats = sideChats, ) } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt index d390b71cd8..58f11f7452 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt @@ -138,6 +138,25 @@ class SessionController private constructor( var pinned: Boolean = false private set + /** + * The owning engine's reported version as of the last refresh + * (`EngineView.version`), `null` while unknown. android-next #06's ⋯ + * reads it on main to offer the side-chat rows only to engines that + * serve them; a stale read only until the next refresh. + */ + @Volatile + var engineVersion: String? = null + private set + + /** + * The chat's workspace row as of the last refresh (`null` before the + * workspace knows it). android-next #06's New Side Chat seeds its draft + * from it on main, with no FFI call. + */ + @Volatile + var sessionRow: SessionRow? = null + private set + private val closed = AtomicBoolean(false) /** The view [attach] bound; a second attach of the same instance is a no-op. Cleared again when the attach FFI failed, so a retry is possible. */ @@ -325,6 +344,8 @@ class SessionController private constructor( val engine = core.engine() hostDeviceId = c.host.deviceId pinned = row?.pinned == true + engineVersion = engine?.version + sessionRow = row // Ticket 89: the local config beside the row — the menus' // deferred loads re-read it at load time (#664), so it rides // along on every refresh. diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt index 8f0de485d4..42370f9f49 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt @@ -173,6 +173,9 @@ object SessionHeaderDefaults { * sit below it * @param hazeState the screen's [HazeState] (ticket 07); `null` = no blur * anywhere (the edge effect's scrim-only fallback, glass fallback fills) + * @param accessory a second 44 dp bar item left of the ⋯ (android-next + * #06: the side chats button, iOS `rightBarButtonItems = [menu, …, + * sideChats]`); `null` = none. The title's cap narrows to clear it. */ @Composable fun SessionHeader( @@ -185,6 +188,9 @@ fun SessionHeader( onBack: () -> Unit = LocalNavStack.current.let { stack -> { stack.pop() } }, statusBarTop: Dp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), hazeState: HazeState? = LocalHazeState.current, + accessory: (@Composable () -> Unit)? = null, + /** android-next #06: false drops the ⋯ (a side chat's draft has no menu). */ + showMore: Boolean = true, ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current @@ -238,8 +244,11 @@ fun SessionHeader( .fillMaxWidth() .height(SessionHeaderDefaults.BarHeight), ) { - val titleMaxWidth = - SessionHeaderMetrics.titleMaxWidth(maxWidth.value, showBack).dp + val titleMaxWidth = SessionHeaderMetrics.titleMaxWidth( + maxWidth.value, + showBack, + extraTrailing = if (accessory != null) SessionHeaderMetrics.AccessoryExtent else 0f, + ).dp if (showBack) { Box( Modifier @@ -260,7 +269,7 @@ fun SessionHeader( ) } } - Box( + if (showMore) Box( Modifier .align(Alignment.CenterEnd) .padding(end = SessionHeaderDefaults.SideMargin + cutoutEnd), @@ -281,6 +290,20 @@ fun SessionHeader( weight = SymbolWeight.Medium, ) } + if (accessory != null) { + // The second bar item: one 44 dp circle plus UIKit's + // 8 dp item gap left of the ⋯. + Box( + Modifier + .align(Alignment.CenterEnd) + .padding( + end = SessionHeaderDefaults.SideMargin + cutoutEnd + + SessionHeaderDefaults.ButtonSize + SessionHeaderDefaults.TitleItemGap, + ), + ) { + accessory() + } + } SessionTitleView( title = title, subtitle = subtitle, diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt index 13b6c8f85d..29a2e1dbf0 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt @@ -33,6 +33,10 @@ object SessionHeaderMetrics { * ⋯ circle always bounds the trailing side. * * Worked values: 402 → 240, 412 → 240, 360 → 224, 320 → 184, 100 → 0. + * + * [extraTrailing] widens the trailing side for a second bar item + * (android-next #06's side chats button: [AccessoryExtent], one more + * circle and the item gap) — 402 → 162, 360 → 120. */ fun titleMaxWidth( width: Float, @@ -41,12 +45,16 @@ object SessionHeaderMetrics { button: Float = SessionHeaderDefaults.ButtonSize.value, gap: Float = SessionHeaderDefaults.TitleItemGap.value, cap: Float = SessionHeaderDefaults.TitleMaxWidth.value, + extraTrailing: Float = 0f, ): Float { val lead = if (showBack) side + button else side - val trail = side + button + val trail = side + button + extraTrailing return minOf(cap, width - 2 * (maxOf(lead, trail) + gap)).coerceAtLeast(0f) } + /** A second trailing bar item's extent: its 44 circle plus the 8 item gap. */ + val AccessoryExtent: Float = SessionHeaderDefaults.ButtonSize.value + SessionHeaderDefaults.TitleItemGap.value + /** * The title view's height: 37.4 with a subtitle (20.8 + 1 + 15.6), the * title line's 20.8 without one. diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt index d5c28ae652..d44cf0803b 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -33,6 +35,7 @@ import sh.roboco.android.design.glass.GlassMenuPanel import sh.roboco.android.design.glass.Menu import sh.roboco.android.design.glass.MenuIcon import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.MenuSection import sh.roboco.android.design.glass.flattenLevel import sh.roboco.android.design.glass.rememberGlassMenuState import sh.roboco.android.sessions.ActionOutcome @@ -84,9 +87,10 @@ fun sessionMenu( onSetPinned: (Boolean) -> Unit, onCopyTranscript: () -> Unit, onArchive: () -> Unit, + sideChats: SideChatMenuRows? = null, ): Menu = Menu( title = null, - children = listOf( + children = listOfNotNull( MenuItem( title = if (pinned) "Unpin" else "Pin", icon = MenuIcon.Symbol(if (pinned) RobocoIcons.PinSlash else RobocoIcons.Pin), @@ -100,6 +104,7 @@ fun sessionMenu( testTag = "session-menu-copy", action = onCopyTranscript, ), + sideChats?.let { sideChatSection(it, canWrite) }, MenuItem( title = "Archive", icon = MenuIcon.Symbol(RobocoIcons.Archivebox), @@ -111,6 +116,39 @@ fun sessionMenu( ), ) +/** + * android-next #06's ⋯ rows (upstream #899's inline group between Copy + * Transcript and Archive): New Side Chat and Fork into Side Chat. `null` + * — an engine without side chats — leaves the menu as it was. [forking] + * disables the fork while one is in flight (one at a time). + */ +class SideChatMenuRows( + val forking: Boolean, + val onNew: () -> Unit, + val onFork: () -> Unit, +) + +/** The inline group (hairlines above and below); both rows need a writable engine. */ +private fun sideChatSection(rows: SideChatMenuRows, canWrite: Boolean): MenuSection = MenuSection( + inline = true, + children = listOf( + MenuItem( + title = "New Side Chat", + icon = MenuIcon.Symbol(RobocoIcons.QuestionmarkBubble), + disabled = !canWrite, + testTag = "session-menu-new-side-chat", + action = rows.onNew, + ), + MenuItem( + title = "Fork into Side Chat", + icon = MenuIcon.Symbol(RobocoIcons.ArrowTriangleBranch), + disabled = !canWrite || rows.forking, + testTag = "session-menu-fork-side-chat", + action = rows.onFork, + ), + ), +) + /** * The [SessionHeader] `onMore` handler (80's step 7): returns a lambda * that builds a fresh [sessionMenu] from the **current** pin state and @@ -134,7 +172,10 @@ fun sessionMenu( fun rememberSessionMenu( controller: SessionController, transcript: TranscriptState, + /** android-next #06: the side-chat rows, read at open time; `null` result = none. */ + sideChats: () -> SideChatMenuRows? = { null }, ): (anchorInWindow: Rect) -> Unit { + val currentSideChats by rememberUpdatedState(sideChats) val menuState = rememberGlassMenuState() val actions = LocalRowActions.current val router = LocalAppRouter.current @@ -171,6 +212,7 @@ fun rememberSessionMenu( } } }, + sideChats = currentSideChats(), ) menuState.open(anchor, menu) } diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt index 775982ee6f..50e5e668fe 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt @@ -2,7 +2,12 @@ package sh.roboco.android.session import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import sh.roboco.android.RobocoApplication import sh.roboco.android.composer.CoreQueueEditPort import sh.roboco.android.composer.CoreQuestionPort import sh.roboco.android.composer.DraftKey @@ -13,6 +18,19 @@ import sh.roboco.android.composer.QueueEditController import sh.roboco.android.composer.QueueEditPort import sh.roboco.android.composer.SessionChipMenus import sh.roboco.android.composer.sessionDraftSaver +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.newsession.SideChatParent +import sh.roboco.android.sessions.rememberSessionListOpener +import sh.roboco.android.sidechats.SideChatDraftRoute +import sh.roboco.android.sidechats.SideChatFork +import sh.roboco.android.sidechats.SideChatsButton +import sh.roboco.android.sidechats.SideChatsModel +import sh.roboco.android.sidechats.SideChatsOverlay +import sh.roboco.android.sidechats.SideChatsSheetController +import sh.roboco.android.sidechats.SideChatsUi +import sh.roboco.android.sidechats.SubagentOpenRequest +import sh.roboco.android.sidechats.rememberSubagentOpener +import sh.roboco.android.sidechats.sideChatsSupported import sh.roboco.android.shell.LocalNavEntry import sh.roboco.android.shell.LocalNavStack import sh.roboco.android.shell.NavRoute @@ -57,6 +75,13 @@ class SessionScreenRoute internal constructor( * routes Edit to 83's QueueEditController and everything else here. */ val queueActions: QueueActions?, + /** + * android-next #06: this chat's side chats and subagents (the header + * button, the sheet, the ⋯ rows), built by [CoreSessionRouteFactory] + * over [core]'s handle; started on first composition, stopped on + * release or discard. Null for a test fake core. + */ + val sideChats: SideChatsModel? = null, ) : SessionRoute, NavRoute { /** @@ -127,6 +152,7 @@ class SessionScreenRoute internal constructor( // Ticket 89: the chip-menu loads resolve empty after this (the // in-flight writes still finish — never cancelled). controller.filler<SessionChipMenus>()?.dispose() + sideChats?.stop() val lifecycle = navLifecycle if (lifecycle != null) { // Ticket 85: detach + mark seen, then the layout engine. @@ -225,6 +251,87 @@ class SessionScreenRoute internal constructor( entry.retain(QuestionsKey) { CoreQuestionPort(live.handle) } } } + // android-next #06: side chats and subagents — the header button + // and its sheet, the ⋯ rows, the spawn-card opener. The sheet state + // and the fork in flight live on the entry (a tab switch keeps them). + val sideChatsModel = sideChats + remember(entry) { + sideChatsModel?.start() + true + } + val sideUi = sideChatsModel?.ui?.collectAsState()?.value ?: SideChatsUi() + val sheet = remember(entry) { entry.retain(SideChatsSheetKey) { SideChatsSheetController() } } + val subagentOpener = rememberSubagentOpener() + val openChat = rememberSessionListOpener() + val app = LocalContext.current.applicationContext as? RobocoApplication + val toaster = LocalToaster.current + val fork: SideChatFork? = remember(entry) { + val live = core as? CoreSessionCore + if (live == null || app == null) { + null + } else { + entry.retain(SideChatForkKey) { + SideChatFork( + fork = { source -> withContext(Dispatchers.IO) { live.client.forkSideChat(source) } }, + scope = app.appScope, + toaster = toaster, + ) + } + } + } + // Read at tap time: an engine that predates side chats offers none. + val sideChatsOffered: () -> Boolean = { + sideChatsModel != null && fork != null && sideChatsSupported(controller.engineVersion) + } + val isTopAndIdle: () -> Boolean = { stack.top === entry && stack.transition == null } + val newSideChat: () -> Unit = { + // iOS `newSideChat()`: the draft pushed on top, seeded from the + // row the last refresh cached (no FFI on main). + val row = controller.sessionRow + if (row != null && isTopAndIdle()) stack.push(SideChatDraftRoute(SideChatParent.of(row)), animated = true) + } + val forkSideChat: () -> Unit = { + fork?.start(controller.chatId) { forkId -> + sideChatsModel?.invalidate() + // iOS opens the fork only while this screen is still shown. + if (isTopAndIdle()) openChat(forkId) + } + } + val openSubagent: (String, String, Boolean) -> Unit = { docId, title, frozen -> + subagentOpener?.open( + SubagentOpenRequest( + docId = docId, + title = title, + frozen = frozen, + spawnerId = controller.chatId, + parentTitle = controller.chrome.value.title, + ), + ) + } + val badge = sideUi.badge + val sideChatsButton: (@Composable () -> Unit)? = if (badge != null) { + { SideChatsButton(badge, onClick = { sheet.open() }) } + } else { + null + } + val sideChatsOverlay: @Composable () -> Unit = { + SideChatsOverlay( + controller = sheet, + ui = sideUi, + canAdd = sideChatsOffered(), + onOpenChat = openChat, + onOpenAgent = { agent -> openSubagent(agent.docId, agent.title, !agent.running) }, + onNew = newSideChat, + onFork = forkSideChat, + ) + } + val onMore = rememberSessionMenu(controller, transcript) { + if (sideChatsOffered()) { + SideChatMenuRows(forking = fork?.forking == true, onNew = newSideChat, onFork = forkSideChat) + } else { + null + } + } // Ticket 85: the push/pop choreography (the stack and jump // multipliers) and the will/did lifecycle wiring around the // screen. The alpha travels through [LocalSessionNavAlpha], so @@ -244,11 +351,14 @@ class SessionScreenRoute internal constructor( questionPort = questionPort, // Ticket 80: the ⋯ menu — the header's onMore opens the session // menu built fresh on every tap (pin state and canWrite read - // at open time). - onMore = rememberSessionMenu(controller, transcript), + // at open time); android-next #06 adds the side-chat rows. + onMore = onMore, // 84: the engine half of the draft key (the route's core has // it; SessionScreen pairs it with the controller's chatId). engineId = core.engineId, + onOpenSubagent = openSubagent, + headerAccessory = sideChatsButton, + overlay = sideChatsOverlay, ) } } @@ -276,6 +386,7 @@ class SessionScreenRoute internal constructor( override fun discard() { // Ticket 89: same rule as release — later loads resolve empty. controller.filler<SessionChipMenus>()?.dispose() + sideChats?.stop() controller.dispose() if (state.transcriptCreated) state.transcript.close() } @@ -290,6 +401,12 @@ class SessionScreenRoute internal constructor( /** 83's queue-edit-port retention key. */ private object QueueEditPortKey + + /** android-next #06's sheet-state retention key. */ + private object SideChatsSheetKey + + /** android-next #06's fork retention key. */ + private object SideChatForkKey /** 91's question-port retention key. */ private object QuestionsKey } diff --git a/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt b/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt index 149f111e89..22fe21dd89 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt @@ -15,6 +15,8 @@ import sh.roboco.android.sessions.FolderRoute import sh.roboco.android.sessions.FolderRouteContent import sh.roboco.android.sessions.SearchScreen import sh.roboco.android.sessions.SessionsScreen +import sh.roboco.android.sidechats.SideChatDraftRoute +import sh.roboco.android.sidechats.SubagentRoute /** * A tab's root route (android-ui-parity #26): the first entry of the @@ -77,6 +79,12 @@ fun ShellRouteContent(route: NavRoute) { // net + 75's SessionScreen). Ticket 106's detail column falls // through to this same table. is SessionScreenRoute -> route.Content() + // android-next #06: a subagent's read-only transcript, pushed from + // a spawn card or the Side Chats & Agents sheet. + is SubagentRoute -> route.Content() + // android-next #06: a new side chat's draft, pushed from the ⋯ or + // the sheet's +; its first send swaps the chat in for it. + is SideChatDraftRoute -> route.Content() // Ticket 140: the Engine detail page — the status card, the // actions card (Rename…, Pair again) and the Forget confirm the // Devices row's push lands on. 104's tablet Settings dialog routes diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt new file mode 100644 index 0000000000..1678ff44dc --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt @@ -0,0 +1,103 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.newsession.NewSessionScreenBody +import sh.roboco.android.newsession.SideChatHeadline +import sh.roboco.android.newsession.SideChatParent +import sh.roboco.android.newsession.rememberCoreFileSearch +import sh.roboco.android.newsession.rememberNewSessionModel +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.shell.LocalNavEntry +import sh.roboco.android.shell.LocalNavStack +import sh.roboco.android.shell.LocalRouterHooks +import sh.roboco.android.shell.NavRoute +import sh.roboco.android.shell.OPEN_ERROR_TOAST +import sh.roboco.android.shell.openSessionRoute + +/** The draft's bar title and subtitle (upstream #899's `sideOf` title view). */ +object SideChatDraftCopy { + const val Title = "New Side Chat" + + fun subtitle(parentTitle: String): String = + if (parentTitle.isBlank()) "Side chat" else "Side chat · $parentTitle" +} + +/** + * A new, empty side chat (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`): the New Session canvas pushed over + * the session, starting on the parent's model. It runs in the parent's + * checkout on the parent's host, so only the model and effort chips show + * (any harness, until the first send mints the row). Nothing is written + * until that send — leaving first leaves nothing; a draft opened from a + * side chat makes a sibling (the core hangs it off the same root). + * + * The first send creates the chat (`createChat` with `parentChatId`), + * opens it, and swaps it in for this draft, in place (iOS `arrive(at:)`). + * + * Identity equality on purpose (a plain class): `NavStack.setRoutes` + * matches routes by equality, and two drafts are two screens. + */ +class SideChatDraftRoute(val parent: SideChatParent) : NavRoute { + override val hidesBottomChrome: Boolean get() = true + + override val paneTitle: String get() = SideChatDraftCopy.Title + + @Composable + fun Content() { + SideChatDraftScreen(this) + } +} + +@Composable +private fun SideChatDraftScreen(route: SideChatDraftRoute) { + val nav = LocalNavStack.current + val entry = LocalNavEntry.current + val hooks = LocalRouterHooks.current + val toaster = LocalToaster.current + val scope = rememberCoroutineScope() + val model = rememberNewSessionModel(prompt = null, side = route.parent) + Box(Modifier.fillMaxSize()) { + NewSessionScreenBody( + ns = model, + embedded = true, + onCreated = { chatId, _ -> + // iOS `arrive(at:)`: open the chat, then swap it in for the + // draft, in place (no push animation; the draft's own + // composer already cleared). A draft the user left + // meanwhile opens nothing — the side chat lists under its + // parent. + scope.launch { + val session = hooks.openSessionRoute(chatId) + when { + session == null -> { + toaster.show(OPEN_ERROR_TOAST) + if (nav.top === entry && nav.transition == null) nav.pop(animated = true) + } + entry !in nav.entries -> session.discard() + else -> nav.setRoutes( + nav.entries.map { if (it === entry) session as NavRoute else it.route }, + ) + } + } + }, + onClose = {}, + focusOnAppear = true, + fileSearch = rememberCoreFileSearch(), + headline = SideChatHeadline, + navBar = false, + ) + SessionHeader( + title = SideChatDraftCopy.Title, + subtitle = SideChatDraftCopy.subtitle(route.parent.title), + edgeEngaged = false, + onMore = {}, + showMore = false, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt new file mode 100644 index 0000000000..01e9b94884 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt @@ -0,0 +1,77 @@ +package sh.roboco.android.sidechats + +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import sh.roboco.android.design.Toaster +import uniffi.roboco_core.CoreException + +/** The fork's failure copy, verbatim from upstream #899's `AppModel.forkSideChat`. */ +object SideChatForkCopy { + const val Failed = "Couldn't fork this session." + const val Unsupported = "This engine doesn't support side chats. Update it to fork." + + fun unreachable(reason: String): String = "The host is unreachable ($reason)." +} + +/** + * The toast for a failed fork (iOS: the host's reason for a `HostError` — + * "nothing to fork yet" when no reply has completed —, "The host is + * unreachable (…)" offline, else the generic line). Pure. + */ +fun forkErrorText(error: Throwable): String = when (error) { + is CoreException.HostException -> error.reason.ifBlank { SideChatForkCopy.Failed } + is CoreException.HostUnavailable -> SideChatForkCopy.unreachable(error.reason) + is CoreException.Unsupported -> SideChatForkCopy.Unsupported + else -> SideChatForkCopy.Failed +} + +/** + * Fork into Side Chat (android-next #06, upstream #899's `forkSideChat`): + * the engine copies the session's history through its last completed + * reply into a side chat on its host (`ForkSideChat`; from a side chat, a + * sibling), and [onForked] opens it. One fork at a time, like the + * desktop's; [forking] is snapshot state for the ⋯ row's disabled flag. + * A failure toasts [forkErrorText]. + * + * [fork] is `CoreClient.forkSideChat` (a seam: the generated client is a + * JNA object tests cannot build); it runs on [scope], which must outlive + * the screen (the app scope), so a fork tapped just before Back still + * lands — [onForked] then decides whether to open it. + */ +@Stable +class SideChatFork( + private val fork: suspend (sourceId: String) -> String, + private val scope: CoroutineScope, + private val toaster: Toaster, + private val logW: (String, Throwable?) -> Unit = { message, error -> Log.w("RobocoSideChats", message, error) }, +) { + var forking: Boolean by mutableStateOf(false) + private set + + /** Main thread. A second call while one runs is ignored. */ + fun start(sourceId: String, onForked: (forkId: String) -> Unit) { + if (forking) return + forking = true + scope.launch { + val forkId = try { + fork(sourceId) + } catch (cancelled: CancellationException) { + forking = false + throw cancelled + } catch (t: Throwable) { + logW("fork side chat failed", t) + forking = false + toaster.show(forkErrorText(t)) + return@launch + } + forking = false + onForked(forkId) + } + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt new file mode 100644 index 0000000000..94f53f2482 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt @@ -0,0 +1,72 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import sh.roboco.android.core.Face +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.SymbolWeight +import sh.roboco.android.design.glass.GlassCircleButton +import sh.roboco.android.design.uiStyle +import sh.roboco.android.session.SessionHeaderDefaults + +/** "Side chats and agents" (iOS `accessibilityLabel`), plus "N running" while anything runs. */ +fun sideChatsButtonLabel(badge: SideChatsBadge): String = + listOfNotNull("Side chats and agents", badge.a11yValue).joinToString(", ") + +/** + * The session header's side chats item (android-next #06, upstream #899's + * `bubble.left.and.bubble.right` bar button): a 44 dp glass circle with the + * two-bubble glyph and a badge on its top-trailing corner — `● N` on the + * accent with white ink while subagents run, else a quiet count of + * everything hanging off the chat on `secondary` with background ink (the + * default red reads as unread). The session shows it only once the chat + * has something ([SideChatsUi.badge] non-null). + */ +@Composable +fun SideChatsButton(badge: SideChatsBadge, onClick: () -> Unit, modifier: Modifier = Modifier) { + val palette = RobocoTheme.palette + val fill = if (badge.accent) palette.accent else palette.secondary + val ink = if (badge.accent) Color.White else palette.background + Box(modifier) { + GlassCircleButton( + icon = RobocoIcons.TabChat, + contentDescription = sideChatsButtonLabel(badge), + onClick = onClick, + modifier = Modifier.testTag("side-chats"), + size = SessionHeaderDefaults.ButtonSize, + iconPt = SessionHeaderDefaults.IconPt, + weight = SymbolWeight.Medium, + ) + // The badge: decorative (the button's label carries the count), so + // it never takes a touch or a TalkBack stop of its own. + BasicText( + badge.text, + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-2).dp) + .background(fill, RoundedCornerShape(50)) + .defaultMinSize(minWidth = 16.dp, minHeight = 16.dp) + .padding(horizontal = 4.5.dp, vertical = 1.dp) + .testTag("side-chats-badge") + .clearAndSetSemantics { }, + style = uiStyle(Face.SansSemibold, LocalTypeScale.current.fixed(11f), ink).copy(textAlign = TextAlign.Center), + maxLines = 1, + softWrap = false, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt new file mode 100644 index 0000000000..f2e789c158 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt @@ -0,0 +1,494 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import sh.roboco.android.core.Face +import sh.roboco.android.design.FadingText +import sh.roboco.android.design.Haptic +import sh.roboco.android.design.LocalFeedback +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.StatusGlyph +import sh.roboco.android.design.StatusGlyphKind +import sh.roboco.android.design.glass.GlassCircleButton +import sh.roboco.android.design.glass.Menu +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.rememberGlassMenuState +import sh.roboco.android.design.rememberSmoothRoundedShape +import sh.roboco.android.design.sheet.GlassSheet +import sh.roboco.android.design.sheet.GlassSheetDefaults +import sh.roboco.android.design.sheet.SheetDetent +import sh.roboco.android.design.sheet.rememberGlassSheetState +import sh.roboco.android.design.uiStyle +import sh.roboco.android.sessions.SectionHeaderRow +import sh.roboco.android.sessions.SectionHeaderState +import sh.roboco.android.sessions.SectionLive +import sh.roboco.android.sessions.SessionRow +import sh.roboco.android.sessions.rememberRowHighlight + +/** The sheet's three foldable sections, in order (upstream #899's `Section`). */ +enum class SideChatsSection(val id: String, val title: String) { + Running("running", "Running"), + Chats("chats", "Side chats"), + Finished("finished", "Finished agents"), +} + +/** + * Which sections are folded, remembered across sheets for the process + * (iOS keeps the set in `UserDefaults`; Roboco keeps it in memory — a + * relaunch unfolds everything). Snapshot state, main thread only. + */ +object SideChatsFolds { + private val folded = mutableStateMapOf<SideChatsSection, Boolean>() + + fun isFolded(section: SideChatsSection): Boolean = folded[section] == true + + fun toggle(section: SideChatsSection) { + folded[section] = !isFolded(section) + } + + /** Unfold everything (tests: the memory is process-wide). */ + internal fun clear() { + folded.clear() + } +} + +/** + * Whether the Side Chats & Agents sheet shows, plus the one action that + * waits for its dismissal (iOS `dismiss(animated:) { onNew() }`: the new + * side chat and the fork start once the sheet is gone). Main thread only. + */ +@Stable +class SideChatsSheetController { + var showing by mutableStateOf(false) + private set + + private var afterDismiss: (() -> Unit)? = null + + fun open() { + showing = true + } + + /** Run [action] once the sheet has animated away. */ + internal fun dismissThen(action: () -> Unit) { + afterDismiss = action + } + + /** `GlassSheet`'s `onDismiss`: the sheet reached `Hidden`. */ + internal fun onDismissed() { + showing = false + val action = afterDismiss + afterDismiss = null + action?.invoke() + } +} + +/** + * The Side Chats & Agents sheet (android-next #06, the port of upstream + * #899's `SideChatsViewController`): everything hanging off one session — + * the subagents its agent is running (live tail and model), its side + * chats (forked, started by hand or spawned through the MCP server), and + * the subagents that finished — in three foldable sections. Picking a row + * opens it on top of the session; the header's + offers New Side Chat and + * Fork into Side Chat; ✓ dismisses; the sheet closes itself once nothing + * is left in it. + * + * A `GlassSheet` at `[Medium, Large]` with a grabber. Host it as the + * **last child of the session screen** so it covers the header, the + * transcript and the bottom stack. + * + * @param canAdd whether the + shows: the engine serves side chats and is + * writable + */ +@Composable +fun SideChatsOverlay( + controller: SideChatsSheetController, + ui: SideChatsUi, + canAdd: Boolean, + onOpenChat: (chatId: String) -> Unit, + onOpenAgent: (SideChatsAgent) -> Unit, + onNew: () -> Unit, + onFork: () -> Unit, +) { + if (!controller.showing) return + val detents = remember { listOf(SheetDetent.Medium, SheetDetent.Large) } + GlassSheet( + onDismiss = { controller.onDismissed() }, + modifier = Modifier.testTag("side-chats-sheet"), + state = rememberGlassSheetState(detents), + grabber = true, + title = SideChatsCopy.Title, + header = { + SideChatsHeader( + canAdd = canAdd, + onNew = { + controller.dismissThen(onNew) + dismiss() + }, + onFork = { + controller.dismissThen(onFork) + dismiss() + }, + onDone = { dismiss() }, + ) + }, + ) { bottomInset -> + // iOS `reload`: an emptied sheet (the last side chat archived) + // closes itself. + val empty = ui.total == 0 + LaunchedEffect(empty) { if (empty) dismiss() } + SideChatsList( + ui = ui, + bottomInset = bottomInset.value, + onOpenChat = { id -> + onOpenChat(id) + dismiss() + }, + onOpenAgent = { agent -> + onOpenAgent(agent) + dismiss() + }, + ) + } +} + +/** The sheet's copy, verbatim from upstream #899. */ +object SideChatsCopy { + const val Title = "Side Chats & Agents" + const val NewSideChat = "New Side Chat" + const val ForkIntoSideChat = "Fork into Side Chat" + const val AddLabel = "Add side chat" +} + +/** The + menu (iOS `UIMenu` on the leading bar item). Pure. */ +fun sideChatsAddMenu(onNew: () -> Unit, onFork: () -> Unit): Menu = Menu( + title = null, + children = listOf( + MenuItem( + title = SideChatsCopy.NewSideChat, + icon = MenuIcon.Symbol(RobocoIcons.QuestionmarkBubble), + testTag = "side-chats-new", + action = onNew, + ), + MenuItem( + title = SideChatsCopy.ForkIntoSideChat, + icon = MenuIcon.Symbol(RobocoIcons.ArrowTriangleBranch), + testTag = "side-chats-fork", + action = onFork, + ), + ), +) + +/** + * The sheet's nav bar: [SheetHeaderRow][sh.roboco.android.design.sheet.SheetHeaderRow]'s + * geometry (52 dp, 16 dp insets, 44 dp glass circles, the centred + * SemiBold 17 title) with the + menu leading and the accent ✓ trailing. + */ +@Composable +private fun SideChatsHeader( + canAdd: Boolean, + onNew: () -> Unit, + onFork: () -> Unit, + onDone: () -> Unit, +) { + val palette = RobocoTheme.palette + val typeScale = LocalTypeScale.current + val menuState = rememberGlassMenuState() + // Written during layout, read only in the click lambda. + var addBounds by remember { mutableStateOf(Rect.Zero) } + Box(Modifier.fillMaxWidth().height(GlassSheetDefaults.HeaderHeight)) { + BasicText( + SideChatsCopy.Title, + Modifier + .align(Alignment.Center) + .semantics { heading() }, + style = uiStyle(Face.SansSemibold, typeScale.fixed(17f), palette.text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (canAdd) { + GlassCircleButton( + icon = RobocoIcons.Plus, + contentDescription = SideChatsCopy.AddLabel, + onClick = { menuState.open(addBounds, sideChatsAddMenu(onNew, onFork)) }, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .onGloballyPositioned { addBounds = it.boundsInWindow() } + .testTag("side-chats-add"), + ) + } + GlassCircleButton( + icon = RobocoIcons.Checkmark, + contentDescription = "Done", + onClick = onDone, + filled = palette.accent, + ink = Color.White, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .testTag("sheet-done"), + ) + } +} + +/** + * The three sections (iOS's diffable snapshot): a header row each — the + * count over all its rows, folded or not; Running keeps its spinner while + * folded — then its rows unless folded. Empty sections are left out. + */ +@Composable +internal fun SideChatsList( + ui: SideChatsUi, + bottomInset: Float, + onOpenChat: (String) -> Unit, + onOpenAgent: (SideChatsAgent) -> Unit, + modifier: Modifier = Modifier, +) { + val openChat by rememberUpdatedState(onOpenChat) + val openAgent by rememberUpdatedState(onOpenAgent) + LazyColumn( + modifier.fillMaxSize().testTag("side-chats-list"), + contentPadding = PaddingValues(bottom = (bottomInset + 16f).dp), + ) { + section(SideChatsSection.Running, ui.running.size, live = true) { + items(ui.running, key = { "agent:${it.docId}" }) { agent -> + SubagentRowView(agent, onClick = { openAgent(agent) }) + } + } + section(SideChatsSection.Chats, ui.chats.size, live = false) { + items(ui.chats, key = { "chat:${it.id}" }) { vm -> + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src) + SessionRow( + vm, + modifier = Modifier.clickable(src, indication = null, role = Role.Button) { openChat(vm.id) }, + highlight = { highlight.value }, + ) + } + } + section(SideChatsSection.Finished, ui.finished.size, live = false) { + items(ui.finished, key = { "agent:${it.docId}" }) { agent -> + SubagentRowView(agent, onClick = { openAgent(agent) }) + } + } + } +} + +/** One section: its header (a tap folds it) and, unfolded, its rows. */ +private fun LazyListScope.section( + section: SideChatsSection, + count: Int, + live: Boolean, + rows: LazyListScope.() -> Unit, +) { + if (count == 0) return + val folded = SideChatsFolds.isFolded(section) + item(key = "header:${section.id}") { + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src, fadeOnRelease = false) + val feedback = LocalFeedback.current + SectionHeaderRow( + SectionHeaderState( + id = "side-${section.id}", + title = section.title, + count = count, + collapsed = folded, + live = if (live) SectionLive.Spinner else null, + ), + highlight = { highlight.value }, + modifier = Modifier.clickable(src, indication = null, role = Role.Button) { + feedback.haptic(Haptic.Select) // iOS `toggle`: UISelectionFeedbackGenerator + SideChatsFolds.toggle(section) + }, + ) + } + if (!folded) rows() +} + +/** `now` / `34m` / `4h` / `2d` (`mc::relative_time_label`, pure Kotlin so the sheet stays FFI-free). */ +internal fun relativeAgentTime(atMs: Long, nowMs: Long): String { + val secs = (nowMs - atMs).coerceAtLeast(0L) / 1000L + return when { + secs < 60L -> "now" + secs < 3_600L -> "${secs / 60L}m" + secs < 86_400L -> "${secs / 3_600L}h" + else -> "${secs / 86_400L}d" + } +} + +/** A subagent row's spoken label (iOS `SubagentCell.configure`). Pure. */ +internal fun subagentRowA11yLabel(agent: SideChatsAgent): String { + val word = when (agent.state) { + AgentState.Running -> "Running" + AgentState.Done -> "Done" + AgentState.Failed -> "Failed" + } + return listOfNotNull( + "Subagent", + agent.title, + word, + agent.model?.let(::capitalizedModel), + if (agent.running) agent.tail else null, + ).joinToString(", ") +} + +/** "haiku" → "Haiku" (iOS `prefix(1).uppercased() + dropFirst()`). */ +internal fun capitalizedModel(model: String): String = + if (model.isEmpty()) model else model.substring(0, 1).uppercase() + model.substring(1) + +/** + * A subagent row (the port of `SubagentCell`): its status glyph stands + * where a side chat shows its harness mark, so the two read apart — + * + * ``` + * ⠿ Audit the reduced-motion fallback ··········· Haiku + * Reading RowView.swift… (running: live tail) + * ✓ Check CJK line breaking ······················ 12m + * ``` + * + * 62k tall while running (title at 10k, tail at 35k), 46k once finished + * (the title centred). The pressed fill is the session row's: controlFill, + * 16 dp continuous corners, inset 8 × 1. + */ +@Composable +internal fun SubagentRowView( + agent: SideChatsAgent, + onClick: () -> Unit, + modifier: Modifier = Modifier, + nowMs: Long = System.currentTimeMillis(), +) { + val palette = RobocoTheme.palette + val ts = LocalTypeScale.current + val running = agent.running + val glyph = when (agent.state) { + AgentState.Running -> StatusGlyphKind.Spinner + AgentState.Done -> StatusGlyphKind.Check(palette.status.done) + AgentState.Failed -> StatusGlyphKind.Dot(palette.status.failed) + } + // Running: which model it runs on. Finished: when it was spawned. + val trailing = if (running) agent.model?.let(::capitalizedModel) else relativeAgentTime(agent.spawnedAtMs, nowMs) + val trailingColor = when { + running -> palette.secondary + agent.state == AgentState.Failed -> palette.status.failed + else -> palette.status.time + } + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src) + val shape = rememberSmoothRoundedShape(16.dp) + val titleH = ts.scaledDp(22f) + Box( + modifier + .fillMaxWidth() + .height(ts.scaledDp(if (running) 62f else 46f)) + .drawWithCache { + val inset = 8.dp.toPx() + val vInset = 1.dp.toPx() + val outline = shape.createOutline( + Size(size.width - 2f * inset, size.height - 2f * vInset), + layoutDirection, + this, + ) + onDrawBehind { + val h = highlight.value.coerceIn(0f, 1f) + if (h > 0f) translate(inset, vInset) { drawOutline(outline, palette.controlFill, alpha = h) } + } + } + .clickable(src, indication = null, role = Role.Button, onClick = onClick) + .clearAndSetSemantics { + testTag = "subagent-${agent.docId}" + contentDescription = subagentRowA11yLabel(agent) + }, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .align(if (running) Alignment.TopStart else Alignment.CenterStart) + .padding(top = if (running) ts.scaledDp(10f) else 0.dp), + ) { + Row(Modifier.fillMaxWidth().height(titleH), verticalAlignment = Alignment.CenterVertically) { + // The 20 dp mark column, the 12 dp glyph centred in it. + Box(Modifier.width(20.dp), contentAlignment = Alignment.Center) { + Box(Modifier.size(12.dp)) { StatusGlyph(glyph) } + } + Spacer(Modifier.width(14.dp)) + FadingText( + agent.title, + uiStyle( + Face.SansMedium, + ts.scaled(if (running) 16.5f else 15.5f), + if (running) palette.text else palette.text.copy(alpha = 0.88f), + ), + Modifier.weight(1f), + ) + if (trailing != null) { + Spacer(Modifier.width(10.dp)) + BasicText( + trailing, + style = uiStyle(Face.SansMedium, ts.scaled(13f), trailingColor).copy(textAlign = TextAlign.End), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + } + val tail = agent.tail + if (running && tail != null) { + FadingText( + tail, + uiStyle(Face.Sans, ts.scaled(13.5f), palette.secondary), + Modifier + .padding(start = 34.dp, top = ts.scaledDp(3f)) + .height(ts.scaledDp(18f)), + ) + } + } + } +} From c7f6ec9689618fb711c3ea5cc17affa614e5535e Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Thu, 15 Oct 2026 21:19:39 +0700 Subject: [PATCH 08/13] feat(android): running-agents pill on session rows (android-next#06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionRowVM carries runningSubagents; the row draws '● N' before the status glyph (upstream #899's SessionCell geometry) and speaks it. Co-Authored-By: Claude <noreply@anthropic.com> --- .../sh/roboco/android/sessions/SessionRow.kt | 52 +++++++++++++++++-- .../android/sessions/SessionRowGeometry.kt | 34 +++++++++++- .../roboco/android/sessions/SessionRowVM.kt | 20 ++++++- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt index b038441c84..e6259ef610 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt @@ -1,12 +1,16 @@ package sh.roboco.android.sessions +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Size @@ -17,6 +21,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints @@ -89,10 +95,19 @@ fun SessionRow( val glyph = c?.glyph(p) val shape = rememberSmoothRoundedShape(SessionRowTokens.CornerRadius) val hl by rememberUpdatedState(highlight) + // android-next #06: the "● N" pill — its text measured here (one + // measure per child per pass), so the frames know its width. + val agentsText = if (vm.runningSubagents > 0) "● ${vm.runningSubagents}" else null + val agentsStyle = uiStyle(Face.SansMedium, ts.scaled(11.5f), p.status.working) + val agentsMeasurer = rememberTextMeasurer() + val agentsTextWidthPx = remember(agentsText, agentsStyle, density) { + agentsText?.let { agentsMeasurer.measure(it, agentsStyle, maxLines = 1, softWrap = false).size.width } ?: 0 + } - // The content has exactly five children, always in this order, so the - // measure policy's indices are stable. The glyph stays an empty Box - // when there is nothing to draw, and the meta slot is always present. + // The content has exactly six children, always in this order, so the + // measure policy's indices are stable. The glyph and the pill stay an + // empty Box when there is nothing to draw, and the meta slot is always + // present. Layout( content = { HarnessMark(vm.harness ?: "claude-code", SessionRowGeometry.MARK_SIDE.dp, p.text) @@ -106,6 +121,7 @@ fun SessionRow( ) if (glyph != null) StatusGlyph(glyph) else Box(Modifier) Box { meta() } + if (agentsText != null) RunningAgentsPill(agentsText, agentsStyle) else Box(Modifier) }, modifier = modifier .fillMaxWidth() @@ -155,7 +171,14 @@ fun SessionRow( // measurable may only be measured once per pass, so the placeable // is right-aligned inside the ceil'd frame at placement instead. val wordP = measurables[2].measure(Constraints(maxWidth = SessionRowGeometry.WORD_MAX_WIDTH.px())) - val f = SessionRowGeometry.frames(W, ts.factor, titleFace, wordP.width / d, glyph != null) + val f = SessionRowGeometry.frames( + W, + ts.factor, + titleFace, + wordP.width / d, + glyph != null, + agentsTextWidth = agentsTextWidthPx / d, + ) val markP = measurables[0].measure(Constraints.fixed(f.mark.w.px(), f.mark.h.px())) val titleP = measurables[1].measure(Constraints.fixed(f.title.w.px(), f.title.h.px())) @@ -163,6 +186,10 @@ fun SessionRow( if (f.glyph != null) Constraints.fixed(f.glyph.w.px(), f.glyph.h.px()) else Constraints.fixed(0, 0), ) val metaP = measurables[4].measure(Constraints.fixed(f.metaSlot.w.px(), f.metaSlot.h.px())) + val agents = f.agents + val agentsP = measurables[5].measure( + if (agents != null) Constraints.fixed(agents.w.px(), agents.h.px()) else Constraints.fixed(0, 0), + ) layout(constraints.maxWidth, f.height.px()) { // Absolute place(), never placeRelative(): the iOS hand layout @@ -181,6 +208,23 @@ fun SessionRow( glyphP.place(0, 0) } metaP.place(f.metaSlot.x.px(), f.metaSlot.y.px()) + if (agents != null) agentsP.place(agents.x.px(), agents.y.px()) else agentsP.place(0, 0) } } } + +/** + * The "● N" running-agents pill (android-next #06, upstream #899's + * `SessionCell.agents`): the count in the working tone, centred on a + * `controlFill` capsule the row's measure sizes (`ceil(text) + 12` × + * `R(18k)`). Decorative — the row's label speaks the count. + */ +@Composable +private fun RunningAgentsPill(text: String, style: TextStyle) { + Box( + Modifier.background(RobocoTheme.palette.controlFill, RoundedCornerShape(50)), + contentAlignment = Alignment.Center, + ) { + BasicText(text, style = style.copy(textAlign = TextAlign.Center), maxLines = 1, softWrap = false) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt index 6bbc2c84af..39033bf1cb 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt @@ -34,6 +34,8 @@ data class SessionRowFrames( val metaSlot: RectDp, /** The background rect: the row bounds inset 8 left/right, 1 top/bottom. */ val highlight: RectDp, + /** android-next #06: the "● N" running-agents pill; null when none shows. */ + val agents: RectDp? = null, ) /** @@ -77,12 +79,33 @@ object SessionRowGeometry { /** `sizeThatFits(CGSize(width: 140, height: 40))`'s cap (a safety bound). */ const val WORD_MAX_WIDTH = 140f + /** android-next #06's pill: `(18k).rounded()` high (upstream #899's `SessionCell`). */ + const val AGENTS_HEIGHT = 18f + + /** The pill's text padding: `sizeThatFits(…).width + 12`. */ + const val AGENTS_PADDING = 12f + + /** The pill's overlap into the title gap (`trailing + 4 − w`). */ + const val AGENTS_OVERLAP = 4f + + /** The title's gap before the pill (`trailing = agents.minX − 8`). */ + const val AGENTS_GAP = 8f + + /** + * @param agentsTextWidth android-next #06: the "● N" text's single-line + * width (dp, unrounded); 0 = no pill. The pill leads the status glyph + * and word, never replaces them (upstream #899's `SessionCell`): it is + * `ceil(text) + 12` wide and `R(18k)` high, centred on the title line, + * its right edge 4 dp into the title's 10 dp gap, and the title ends + * 8 dp before it. + */ fun frames( width: Float, factor: Float, titleFace: Face, wordWidth: Float, hasGlyph: Boolean, + agentsTextWidth: Float = 0f, ): SessionRowFrames { val r = ::roundHalfAway val titleY = r(10f * factor) @@ -108,7 +131,15 @@ object SessionRowGeometry { } else { null } - val trailing = (glyph?.x ?: wordX) - TITLE_GAP + var trailing = (glyph?.x ?: wordX) - TITLE_GAP + val agents = if (agentsTextWidth > 0f) { + val h = r(AGENTS_HEIGHT * factor) + val w = ceil(agentsTextWidth) + AGENTS_PADDING + RectDp(trailing + AGENTS_OVERLAP - w, titleY + (titleH - h) / 2f, w, h) + } else { + null + } + if (agents != null) trailing = agents.x - AGENTS_GAP return SessionRowFrames( height = height, mark = RectDp(MARGIN, markY, MARK_SIDE, MARK_SIDE), @@ -117,6 +148,7 @@ object SessionRowGeometry { glyph = glyph, metaSlot = RectDp(TEXT_X, metaY, max(0f, width - MARGIN - TEXT_X), metaH), highlight = RectDp(8f, 1f, max(0f, width - 16f), max(0f, height - 2f)), + agents = agents, ) } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt index 280a75dcbf..bfb614f98f 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt @@ -39,6 +39,12 @@ data class SessionRowVM( val unseen: Boolean, val pinned: Boolean, val sendFailed: Boolean, + /** + * android-next #06 (upstream #899's `runningSubagents`): agents running + * under this session right now — its open transcript's running + * subagents plus its working side chats. The "● N" pill. + */ + val runningSubagents: Int = 0, ) { /** * `SessionRowVM.Status` (`SessionCell.swift:4-6`). Not the FFI enum: @@ -74,6 +80,7 @@ data class SessionRowVM( unseen = r.unseen, pinned = r.pinned, sendFailed = r.sendState == SendState.FAILED, + runningSubagents = r.runningSubagents.toInt(), ) } } @@ -101,8 +108,9 @@ object HomeColorIndex { /** * The row's accessibility label — `SessionCell.swift:152`: title, project - * name, the corner word (never the time label), and the PR badge's label - * only when both `pr` and `prNumber` are non-nil. + * name, the corner word (never the time label), the running agents + * ("2 subagents running", android-next #06), and the PR badge's label only + * when both `pr` and `prNumber` are non-nil. */ fun sessionRowA11yLabel(vm: SessionRowVM): String { val pr = vm.pr @@ -111,6 +119,14 @@ fun sessionRowA11yLabel(vm: SessionRowVM): String { vm.title, vm.projectName, corner(vm)?.word, + runningAgentsLabel(vm.runningSubagents), if (pr != null && number != null) prBadgeA11yLabel(number, pr) else null, ).joinToString(", ") } + +/** The "● N" pill's spoken label (upstream #899's `SessionCell`); `null` at 0. */ +fun runningAgentsLabel(running: Int): String? = when { + running <= 0 -> null + running == 1 -> "1 subagent running" + else -> "$running subagents running" +} From e80474024eaa59c846ae3ca1a0d2cda7a66c3a37 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Thu, 15 Oct 2026 21:19:40 +0700 Subject: [PATCH 09/13] test(android): side chats, subagent links, the pill and the side-chat draft (android-next#06) Co-Authored-By: Claude <noreply@anthropic.com> --- .../android/newsession/SideChatDraftTest.kt | 220 ++++++++++++ .../android/sessions/RunningAgentsPillTest.kt | 89 +++++ .../android/sidechats/SideChatsEntryTest.kt | 259 +++++++++++++++ .../android/sidechats/SideChatsModelTest.kt | 221 ++++++++++++ .../android/sidechats/SideChatsSheetTest.kt | 314 ++++++++++++++++++ .../transcript/widgets/SubagentLinkTest.kt | 92 +++++ 6 files changed, 1195 insertions(+) create mode 100644 apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt create mode 100644 apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt create mode 100644 apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt create mode 100644 apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt create mode 100644 apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt create mode 100644 apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt diff --git a/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt b/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt new file mode 100644 index 0000000000..3ef20c6372 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt @@ -0,0 +1,220 @@ +package sh.roboco.android.newsession + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import sh.roboco.android.composer.DEFAULT_HARNESS +import sh.roboco.android.composer.Drafts +import sh.roboco.android.composer.FakePrefsStore +import sh.roboco.android.composer.foregroundAppScope +import uniffi.roboco_core.NewSession +import uniffi.roboco_core.SendRequest +import uniffi.roboco_core.SessionTarget + +/** + * A side chat's draft (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`): the page model in side mode — it + * starts on the parent's model, picks no project or host, loads models + * for the parent's host and never touches the sticky new-session store — + * and the create it sends: `parentChatId`, the parent's host as the + * (ignored) target, no branch and never a worktree, whatever the draft + * says. + * + * Plain JVM with the same fakes as `NewSessionModelTest`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatDraftTest { + + @Before + fun clearCatalogCache() { + ModelCatalogCache.clear() + } + + private val parent = SideChatParent( + chatId = "engine:v1:k:chat", + title = "Streaming veil on transcript rows", + deviceId = "engine:v1:k:dev", + projectId = "engine:v1:k:space", + harness = "codex", + model = "x1", + effort = "low", + ) + + private class FakeCodec : NewSessionDraftCodec { + private val byToken = HashMap<String, NewSessionDraft>() + private var next = 0 + override fun encode(d: NewSessionDraft): String { + val token = "d${next++}" + byToken[token] = d + return token + } + + override fun decode(json: String?): NewSessionDraft = json?.let { byToken[it] } ?: NewSessionDraft() + } + + private class FakeCatalog(private val builtInModels: List<ModelChoice>) : ModelCatalogSource { + val fetches = mutableListOf<String>() + override fun builtIn(): List<ModelChoice> = builtInModels + override suspend fun fetch(deviceId: String): List<ModelChoice> { + fetches += deviceId + return emptyList() + } + } + + private val builtIn = listOf( + ModelChoice("claude-code", "Claude Code", "c1", "Claude 1", listOf("low", "medium", "high")), + ModelChoice("codex", "Codex", "x1", "Codex 1", listOf("low", "medium")), + ) + + private fun project(id: String) = ProjectOption( + id = id, + name = id, + path = "/$id", + device = "dev-$id", + deviceName = "dev-$id", + online = true, + git = true, + groupKey = "repo:$id", + groupName = id, + colorIndex = 0, + ) + + private fun TestScope.store(): NewSessionDraftStore { + val drafts = Drafts(FakePrefsStore(), foregroundAppScope(), StandardTestDispatcher(testScheduler)) + return NewSessionDraftStore( + FakePrefsStore(), + drafts, + foregroundAppScope(), + StandardTestDispatcher(testScheduler), + launchHarness = { null }, + codec = FakeCodec(), + ) + } + + private fun TestScope.page( + store: NewSessionDraftStore, + catalog: FakeCatalog, + side: SideChatParent?, + ): NewSessionModel { + store.warm() + advanceUntilIdle() + val appScope = foregroundAppScope() + val pageScope = CoroutineScope(appScope.coroutineContext + SupervisorJob(appScope.coroutineContext.job)) + return NewSessionModel( + prompt = null, + store = store, + options = MutableStateFlow(NewSessionOptions(listOf(project("A")), emptyList())), + catalog = catalog, + pageScope = pageScope, + appScope = appScope, + io = StandardTestDispatcher(testScheduler), + main = StandardTestDispatcher(testScheduler), + side = side, + ) + } + + // ------------------------------------------------------------ the page + + @Test + fun aSideDraftStartsOnTheParentsModelAndPicksNoCheckout() = runTest { + val store = store() + store.lastDraft = NewSessionDraft(projectId = "A", harness = "claude-code", model = "c1") + store.text = "a half-typed new session" + val catalog = FakeCatalog(builtIn) + val model = page(store, catalog, parent) + advanceUntilIdle() + + assertEquals(NewSessionDraft(harness = "codex", model = "x1", effort = "low"), model.draft) + assertNull("no project: the parent's checkout", model.project) + assertEquals("the parent's host", parent.deviceId, model.deviceId) + assertEquals("never the new-session text", "", model.initialText) + assertEquals(listOf(parent.deviceId), catalog.fetches) + assertEquals("x1", model.selectedModel?.id) + } + + @Test + fun aSideDraftNeverWritesTheStickyStore() = runTest { + val store = store() + store.lastDraft = NewSessionDraft(projectId = "A") + store.text = "kept" + val model = page(store, FakeCatalog(builtIn), parent) + advanceUntilIdle() + + model.update { it.copy(harness = "claude-code", model = "c1") } + model.persistBeforeCreate() + model.rememberDraft("side text", emptyList()) + assertEquals(NewSessionDraft(projectId = "A"), store.lastDraft) + assertEquals("kept", store.text) + model.markCreated() + assertEquals("a side chat's create leaves the new-session text alone", "kept", store.text) + } + + @Test + fun aParentWithoutAHarnessStartsOnTheDefault() { + assertEquals( + NewSessionDraft(harness = DEFAULT_HARNESS), + sideChatDraft(parent.copy(harness = null, model = null, effort = null)), + ) + } + + // ------------------------------------------------------------ the create + + @Test + fun theCreateNamesTheParentAndIgnoresTheDraftsCheckout() { + val draft = NewSessionDraft(projectId = "A", branch = "topic", worktree = true, harness = "codex", model = "x1") + val target = NewSessionRequests.target(draft, parent) + assertEquals(SessionTarget.Projectless(deviceId = parent.deviceId), target) + val create: NewSession = NewSessionRequests.newSession(draft, target!!, parent) + assertEquals(parent.chatId, create.parentChatId) + assertNull("the parent's checkout: no branch", create.branch) + assertEquals("codex", create.config?.harness) + assertEquals("x1", create.config?.model) + val send: SendRequest = NewSessionRequests.sendRequest(draft, "ask", emptyList(), listOf(project("A")), parent) + assertNull("a side chat never makes a worktree", send.worktree) + assertEquals("ask", send.text) + } + + @Test + fun aNewSessionStillCreatesTopLevel() { + val draft = NewSessionDraft(projectId = "A", branch = "main") + val target = NewSessionRequests.target(draft)!! + assertEquals(SessionTarget.Project(spaceId = "A"), target) + val create = NewSessionRequests.newSession(draft, target) + assertNull(create.parentChatId) + assertEquals("main", create.branch) + } + + @Test + fun theCreatorSendsTheSideCreateThenTheFirstMessage() = runTest { + val created = mutableListOf<NewSession>() + val sent = mutableListOf<Pair<String, SendRequest>>() + val creator = NewSessionCreator( + core = object : NewSessionCore { + override suspend fun createSession(newSession: NewSession): String { + created += newSession + return "engine:v1:k:side" + } + + override suspend fun sendFirst(chatId: String, request: SendRequest) { + sent += chatId to request + } + }, + io = StandardTestDispatcher(testScheduler), + logW = { _, _ -> }, + ) + val id = creator.createAndSend(NewSessionDraft(harness = "codex"), "ask", emptyList(), emptyList(), parent) + assertEquals("engine:v1:k:side", id) + assertEquals(parent.chatId, created.single().parentChatId) + assertEquals("engine:v1:k:side", sent.single().first) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt new file mode 100644 index 0000000000..3aefb2c091 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt @@ -0,0 +1,89 @@ +package sh.roboco.android.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import sh.roboco.android.core.Face +import uniffi.roboco_core.ChatIndicator +import uniffi.roboco_core.SessionRow + +/** + * The sessions list's "● N" pill (android-next #06, upstream #899's + * `SessionCell.agents`): the FFI count reaches the view model, the row's + * label speaks it, and the hand layout puts the pill before the status + * glyph and the word — `ceil(text) + 12` wide, `R(18k)` high, centred on + * the title line, 4 dp into the title's gap — with the title ending 8 dp + * before it. No pill, no change. + * + * Plain JUnit 4 (no JNA: every `from` passes `homeColorIndex = 6`). + */ +class RunningAgentsPillTest { + + private fun row(running: UInt): SessionRow = SessionRow( + id = "abc", + engineId = "engine:v1:alpha", + revision = 1uL, + title = "Streaming veil on transcript rows", + hasTitle = true, + preview = null, + project = null, + deviceId = "device", + deviceName = "MacBook Pro", + deviceOnline = true, + engineOnline = true, + harness = "claude-code", + harnessLabel = null, + model = null, + modelLabel = null, + reasoning = null, + branch = null, + cwd = null, + // Idle: no corner word, so the label is title, project, agents. + indicator = ChatIndicator.IDLE, + hostIndicator = ChatIndicator.IDLE, + workingSinceMs = null, + runningSubagents = running, + lastActivityMs = 0L, + timeLabel = "now", + createdAtMs = 0L, + unseen = false, + archived = false, + pinned = false, + sectionId = null, + pullRequest = null, + sendState = null, + parentChatId = null, + ) + + @Test + fun theCountReachesTheViewModelAndTheLabel() { + val vm = SessionRowVM.from(row(4u), homeColorIndex = 6) + assertEquals(4, vm.runningSubagents) + assertEquals("Streaming veil on transcript rows, MacBook Pro, 4 subagents running", sessionRowA11yLabel(vm)) + val one = SessionRowVM.from(row(1u), homeColorIndex = 6) + assertEquals("Streaming veil on transcript rows, MacBook Pro, 1 subagent running", sessionRowA11yLabel(one)) + val none = SessionRowVM.from(row(0u), homeColorIndex = 6) + assertEquals(0, none.runningSubagents) + assertEquals("Streaming veil on transcript rows, MacBook Pro", sessionRowA11yLabel(none)) + assertNull(runningAgentsLabel(0)) + } + + @Test + fun thePillLeadsTheGlyphAndShortensTheTitle() { + val medium = Face.SansMedium + val plain = SessionRowGeometry.frames(411f, 1f, medium, wordWidth = 13.8f, hasGlyph = true) + assertNull(plain.agents) + val f = SessionRowGeometry.frames(411f, 1f, medium, wordWidth = 13.8f, hasGlyph = true, agentsTextWidth = 17.3f) + val pill = f.agents!! + assertEquals("ceil(17.3) + 12", 30f, pill.w, 0.001f) + assertEquals(18f, pill.h, 0.001f) + // Its right edge sits 4 dp into the title's 10 dp gap before the glyph. + assertEquals(f.glyph!!.x - SessionRowGeometry.TITLE_GAP + 4f, pill.x + pill.w, 0.001f) + // Centred on the 22 dp title line at 10. + assertEquals(10f + (22f - 18f) / 2f, pill.y, 0.001f) + // The title ends 8 dp before the pill. + assertEquals(pill.x - 8f, f.title.x + f.title.w, 0.001f) + assertEquals(plain.word, f.word) + assertEquals(plain.glyph, f.glyph) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt new file mode 100644 index 0000000000..a5af1e8905 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt @@ -0,0 +1,259 @@ +package sh.roboco.android.sidechats + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.Toaster +import sh.roboco.android.design.glass.Glass +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.MenuSection +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.session.SessionHeaderMetrics +import sh.roboco.android.session.SideChatMenuRows +import sh.roboco.android.session.sessionMenu +import uniffi.roboco_core.CoreException + +/** + * The side chats' entry points (android-next #06): the header's second + * bar item with its badge (shown only with something to show, its label + * carrying the running count, the title cap narrowed for it), the ⋯ + * group (New Side Chat / Fork into Side Chat between Copy Transcript and + * Archive, disabled offline or while a fork runs, absent on an engine + * without side chats), the fork's one-at-a-time run and its failure + * copy, and the subagent viewer's subtitle and menu. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w412dp-h900dp-xxhdpi") +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatsEntryTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + @Before + fun fallbackGlass() { + Glass.forceFallback = true + } + + @After + fun restoreGlass() { + Glass.forceFallback = false + } + + private class RecordingToaster : Toaster { + val texts = mutableListOf<String>() + override fun show(text: String, action: String?, onAction: (() -> Unit)?) { + texts += text + } + } + + // ------------------------------------------------------------ header button + + @Test + fun theHeaderButtonCarriesTheBadgeAndOpensTheSheet() { + var taps = 0 + compose.setContent { + RobocoTheme(Appearance.Light) { + SessionHeader( + title = "Streaming veil on transcript rows", + subtitle = "roboco @ MacBook Pro", + edgeEngaged = false, + onMore = {}, + showBack = true, + onBack = {}, + statusBarTop = 24.dp, + accessory = { SideChatsButton(SideChatsBadge(running = 4, total = 9), onClick = { taps++ }) }, + ) + } + } + compose.waitForIdle() + val button = compose.onNodeWithTag("side-chats") + assertEquals( + listOf("Side chats and agents, 4 running"), + button.fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("side-chats-badge", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("session-more").assertExists() + button.performClick() + compose.waitForIdle() + assertEquals(1, taps) + } + + @Test + fun noAccessoryNoButton() { + compose.setContent { + RobocoTheme(Appearance.Light) { + SessionHeader( + title = "A chat with nothing hanging off it", + subtitle = "roboco @ studio", + edgeEngaged = false, + onMore = {}, + showBack = true, + onBack = {}, + statusBarTop = 24.dp, + ) + } + } + compose.waitForIdle() + compose.onNodeWithTag("side-chats").assertDoesNotExist() + } + + @Test + fun theTitleCapClearsTheSecondBarItem() { + assertEquals(240f, SessionHeaderMetrics.titleMaxWidth(402f, showBack = true), 0.001f) + val extra = SessionHeaderMetrics.AccessoryExtent + assertEquals(52f, extra, 0.001f) + assertEquals(162f, SessionHeaderMetrics.titleMaxWidth(402f, showBack = true, extraTrailing = extra), 0.001f) + assertEquals(120f, SessionHeaderMetrics.titleMaxWidth(360f, showBack = true, extraTrailing = extra), 0.001f) + } + + // ------------------------------------------------------------ the ⋯ group + + private fun menu(canWrite: Boolean, rows: SideChatMenuRows?) = sessionMenu( + pinned = false, + canWrite = canWrite, + onSetPinned = {}, + onCopyTranscript = {}, + onArchive = {}, + sideChats = rows, + ) + + @Test + fun theSessionMenuGroupsTheSideChatRowsBetweenCopyAndArchive() { + var news = 0 + var forks = 0 + val m = menu(canWrite = true, rows = SideChatMenuRows(forking = false, onNew = { news++ }, onFork = { forks++ })) + assertEquals(4, m.children.size) + assertEquals("Copy Transcript", (m.children[1] as MenuItem).title) + val group = m.children[2] as MenuSection + assertTrue("an inline group: hairlines above and below", group.inline) + val rows = group.children.map { it as MenuItem } + assertEquals(listOf("New Side Chat", "Fork into Side Chat"), rows.map { it.title }) + assertEquals(RobocoIcons.ArrowTriangleBranch, (rows[1].icon as MenuIcon.Symbol).icon) + assertEquals( + listOf("session-menu-new-side-chat", "session-menu-fork-side-chat"), + rows.map { it.testTag }, + ) + assertFalse(rows[0].disabled) + assertFalse(rows[1].disabled) + rows[0].action() + rows[1].action() + assertEquals(1, news) + assertEquals(1, forks) + assertEquals("Archive", (m.children[3] as MenuItem).title) + } + + @Test + fun offlineOrForkingDisablesTheRowsAndAnOldEngineHasNone() { + val offline = (menu(false, SideChatMenuRows(false, {}, {})).children[2] as MenuSection) + .children.map { it as MenuItem } + assertTrue(offline.all { it.disabled }) + val forking = (menu(true, SideChatMenuRows(true, {}, {})).children[2] as MenuSection) + .children.map { it as MenuItem } + assertFalse("New stays live while a fork runs", forking[0].disabled) + assertTrue("one fork at a time", forking[1].disabled) + val old = menu(true, null) + assertEquals("an engine without side chats: the menu as it was", 3, old.children.size) + assertTrue(old.children.none { it is MenuSection }) + } + + // ------------------------------------------------------------ the fork + + @Test + fun aForkRunsOnceAtATimeAndOpensTheNewChat() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gate = CompletableDeferred<String>() + val sources = mutableListOf<String>() + val opened = mutableListOf<String>() + val toaster = RecordingToaster() + val fork = SideChatFork( + fork = { source -> + sources += source + gate.await() + }, + scope = CoroutineScope(SupervisorJob() + dispatcher), + toaster = toaster, + logW = { _, _ -> }, + ) + fork.start("engine:v1:k:chat") { opened += it } + fork.start("engine:v1:k:chat") { opened += it } + advanceUntilIdle() + assertTrue(fork.forking) + assertEquals("the second tap is ignored", listOf("engine:v1:k:chat"), sources) + + gate.complete("engine:v1:k:fork") + advanceUntilIdle() + assertFalse(fork.forking) + assertEquals(listOf("engine:v1:k:fork"), opened) + assertTrue(toaster.texts.isEmpty()) + } + + @Test + fun aFailedForkToastsTheHostsReason() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val toaster = RecordingToaster() + val opened = mutableListOf<String>() + val fork = SideChatFork( + fork = { throw CoreException.HostException("nothing to fork yet: no reply has completed") }, + scope = CoroutineScope(SupervisorJob() + dispatcher), + toaster = toaster, + logW = { _, _ -> }, + ) + fork.start("engine:v1:k:chat") { opened += it } + advanceUntilIdle() + assertFalse(fork.forking) + assertTrue(opened.isEmpty()) + assertEquals(listOf("nothing to fork yet: no reply has completed"), toaster.texts) + } + + @Test + fun forkFailureCopyFollowsTheErrorKind() { + assertEquals("busy", forkErrorText(CoreException.HostException("busy"))) + assertEquals(SideChatForkCopy.Failed, forkErrorText(CoreException.HostException(" "))) + assertEquals( + "The host is unreachable (Engine connection lost).", + forkErrorText(CoreException.HostUnavailable("Engine connection lost")), + ) + assertEquals(SideChatForkCopy.Unsupported, forkErrorText(CoreException.Unsupported("ForkSideChat"))) + assertEquals(SideChatForkCopy.Failed, forkErrorText(IllegalStateException("boom"))) + } + + // ------------------------------------------------------------ the viewer + + @Test + fun theViewerSubtitleFollowsTheChip() { + assertEquals("Running · Streaming veil", subagentSubtitle(AgentState.Running, "Streaming veil")) + assertEquals("Subagent · Streaming veil", subagentSubtitle(AgentState.Done, "Streaming veil")) + assertEquals("Failed · Streaming veil", subagentSubtitle(AgentState.Failed, "Streaming veil")) + assertEquals("Subagent", subagentSubtitle(AgentState.Done, " ")) + val copy = subagentMenu(onCopyTranscript = {}).children.single() as MenuItem + assertEquals("Copy Transcript", copy.title) + assertEquals(RobocoIcons.DocOnDoc, (copy.icon as MenuIcon.Symbol).icon) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt new file mode 100644 index 0000000000..162c32cbd9 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt @@ -0,0 +1,221 @@ +package sh.roboco.android.sidechats + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.sessions.SessionRowVM +import uniffi.roboco_core.ClientEvent +import uniffi.roboco_core.SubagentInfo +import uniffi.roboco_core.SubagentState + +/** + * The side chats state holder (android-next #06): the sections a pull + * yields ([SideChatsUi.of]), the header badge, the FFI lifecycle mapping, + * the engine-version gate, and [SideChatsModel]'s refresh shape — a pull + * on start, one per hub event for the chat (a `SessionChanged` moves the + * spawn chips, a `WorkspaceChanged` the children), the previous rows kept + * on a failed pull, nothing after stop. + * + * Plain JUnit 4 on a [StandardTestDispatcher]: the hub posts inline and + * the uniffi records/enums touch no JNA. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatsModelTest { + + private fun agent(id: String, state: AgentState, at: Long = 0L) = + SideChatsAgent(docId = "engine:v1:k:chat--sub--$id", title = "Agent $id", state = state, spawnedAtMs = at) + + private fun chat(id: String) = SessionRowVM( + id = id, + title = "Side $id", + projectName = "roboco", + hasProject = true, + colorIndex = 6, + harness = "claude-code", + branch = "main", + pr = null, + prNumber = null, + status = SessionRowVM.Status.Idle, + timeLabel = "5m", + unseen = false, + pinned = false, + sendFailed = false, + ) + + /** A scripted source: the next pull's rows, a failure switch and a pull counter. */ + private class FakeSource : SideChatsSource { + var agents: List<SideChatsAgent> = emptyList() + var children: List<SessionRowVM> = emptyList() + var fail = false + var pulls = 0 + val parents = mutableListOf<String>() + + override fun agents(): List<SideChatsAgent> { + pulls++ + if (fail) error("the session closed") + return agents + } + + override fun children(parentChatId: String): List<SessionRowVM> { + parents += parentChatId + return children + } + } + + // ------------------------------------------------------------ the sections + + @Test + fun runningAndFinishedSplitNewestSpawnFirst() { + val ui = SideChatsUi.of( + listOf( + agent("a", AgentState.Done), + agent("b", AgentState.Running), + agent("c", AgentState.Failed), + agent("d", AgentState.Running), + ), + listOf(chat("x"), chat("y")), + ) + assertEquals(listOf("d", "b"), ui.running.map { it.title.removePrefix("Agent ") }) + assertEquals(listOf("c", "a"), ui.finished.map { it.title.removePrefix("Agent ") }) + assertEquals(listOf("x", "y"), ui.chats.map { it.id }) + assertEquals(6, ui.total) + } + + @Test + fun theBadgeIsTheRunningPillOrAQuietCountAndHidesWhenEmpty() { + assertNull("nothing hangs off the chat: no button", SideChatsUi().badge) + + val running = SideChatsUi.of(listOf(agent("a", AgentState.Running), agent("b", AgentState.Running)), listOf(chat("x"))) + val badge = running.badge!! + assertEquals("● 2", badge.text) + assertTrue(badge.accent) + assertEquals("2 running", badge.a11yValue) + assertEquals("Side chats and agents, 2 running", sideChatsButtonLabel(badge)) + + val quiet = SideChatsUi.of(listOf(agent("a", AgentState.Done)), listOf(chat("x"), chat("y"))).badge!! + assertEquals("3", quiet.text) + assertFalse(quiet.accent) + assertNull(quiet.a11yValue) + assertEquals("Side chats and agents", sideChatsButtonLabel(quiet)) + } + + @Test + fun theFfiLifecycleMapsOneToOne() { + assertEquals(AgentState.Running, agentState(SubagentState.RUNNING)) + assertEquals(AgentState.Done, agentState(SubagentState.DONE)) + assertEquals(AgentState.Failed, agentState(SubagentState.FAILED)) + val mirrored = SideChatsAgent.from( + SubagentInfo( + docId = "engine:v1:k:chat--sub--t1", + title = "Audit the repo", + state = SubagentState.RUNNING, + tail = null, + model = "haiku", + spawnedAtMs = 1_000L, + ), + ) + assertEquals( + SideChatsAgent("engine:v1:k:chat--sub--t1", "Audit the repo", AgentState.Running, null, "haiku", 1_000L), + mirrored, + ) + assertTrue(mirrored.running) + } + + @Test + fun sideChatsNeedAnEngineFromHalfAYearAgoOrAnUnknownOne() { + assertFalse("0.4.x predates ForkSideChat", sideChatsSupported("0.4.3")) + assertTrue(sideChatsSupported("0.5.0")) + assertTrue(sideChatsSupported("v0.9.2")) + assertTrue(sideChatsSupported("1.0.0")) + assertTrue("unknown version: offered (the engine answers)", sideChatsSupported(null)) + assertTrue(sideChatsSupported("dev")) + } + + // ------------------------------------------------------------ the model + + @Test + fun pullsOnStartOnTheChatsEventsAndStopsAfterStop() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val hub = SessionEventHub(post = { it.run() }) + val source = FakeSource().apply { + agents = listOf(agent("a", AgentState.Running)) + children = listOf(chat("x")) + } + val model = SideChatsModel( + parentChatId = "engine:v1:k:chat", + source = source, + hub = hub, + scope = CoroutineScope(SupervisorJob() + dispatcher), + io = dispatcher, + ) + assertNull("no pull before start", model.ui.value.badge) + + model.start() + advanceUntilIdle() + assertEquals(1, source.pulls) + assertEquals(listOf("engine:v1:k:chat"), source.parents) + assertEquals(1, model.ui.value.running.size) + assertEquals(1, model.ui.value.chats.size) + + // The chip settles: a SessionChanged for this chat re-pulls. + source.agents = listOf(agent("a", AgentState.Done)) + hub.onClientEvent(ClientEvent.SessionChanged(chatId = "engine:v1:k:chat", revision = 2uL)) + advanceUntilIdle() + assertEquals(2, source.pulls) + assertTrue(model.ui.value.running.isEmpty()) + assertEquals(1, model.ui.value.finished.size) + + // Another chat's transcript changes nothing here. + hub.onClientEvent(ClientEvent.SessionChanged(chatId = "engine:v1:k:other", revision = 3uL)) + advanceUntilIdle() + assertEquals(2, source.pulls) + + // A new side chat lands with the workspace. + source.children = listOf(chat("x"), chat("y")) + hub.onClientEvent(ClientEvent.WorkspaceChanged(revision = 4uL)) + advanceUntilIdle() + assertEquals(3, source.pulls) + assertEquals(2, model.ui.value.chats.size) + + model.stop() + hub.onClientEvent(ClientEvent.WorkspaceChanged(revision = 5uL)) + model.invalidate() + advanceUntilIdle() + assertEquals("stopped: no more pulls", 3, source.pulls) + } + + @Test + fun aFailedPullKeepsThePreviousRows() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val source = FakeSource().apply { agents = listOf(agent("a", AgentState.Running)) } + val errors = mutableListOf<String>() + val model = SideChatsModel( + parentChatId = "engine:v1:k:chat", + source = source, + hub = SessionEventHub(post = { it.run() }), + scope = CoroutineScope(SupervisorJob() + dispatcher), + io = dispatcher, + logW = { message, _ -> errors += message }, + ) + model.start() + advanceUntilIdle() + assertEquals(1, model.ui.value.running.size) + + source.fail = true + model.invalidate() + advanceUntilIdle() + assertEquals(2, source.pulls) + assertEquals("the rows stay", 1, model.ui.value.running.size) + assertEquals(listOf("side chats refresh failed"), errors) + model.stop() + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt new file mode 100644 index 0000000000..0148b3b5d4 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt @@ -0,0 +1,314 @@ +package sh.roboco.android.sidechats + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.glass.Glass +import sh.roboco.android.design.glass.GlassMenuHost +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.sessions.SessionRowVM + +/** + * The Side Chats & Agents sheet (android-next #06, upstream #899's + * `SideChatsViewController`): the three foldable sections and their rows + * ([SideChatsList] — the header's count over every row, folded or not; a + * tap on a header folds it; empty sections left out), the subagent row + * (its spoken label, the trailing model or age), the row taps, the + + * menu, and the overlay's + hiding on an engine without side chats. + * + * Robolectric + compose-ui-test with the fallback glass (no blur), the + * clock auto-advancing (the sheet's present spring settles in + * `waitForIdle`; the spinners are infinite animations ui-test ignores). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w412dp-h900dp-xxhdpi") +class SideChatsSheetTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + @Before + fun setUp() { + Glass.forceFallback = true + SideChatsFolds.clear() + } + + @After + fun tearDown() { + Glass.forceFallback = false + SideChatsFolds.clear() + } + + private val openedChats = mutableListOf<String>() + private val openedAgents = mutableListOf<SideChatsAgent>() + + private val running = SideChatsAgent( + docId = "engine:v1:k:chat--sub--run", + title = "Audit the reduced-motion fallback", + state = AgentState.Running, + model = "haiku", + spawnedAtMs = 0L, + ) + private val done = SideChatsAgent( + docId = "engine:v1:k:chat--sub--done", + title = "Check CJK line breaking", + state = AgentState.Done, + spawnedAtMs = 0L, + ) + private val failed = SideChatsAgent( + docId = "engine:v1:k:chat--sub--fail", + title = "Run the veil test matrix", + state = AgentState.Failed, + spawnedAtMs = 0L, + ) + + private fun chat(id: String, title: String) = SessionRowVM( + id = id, + title = title, + projectName = "roboco", + hasProject = true, + colorIndex = 6, + harness = "claude-code", + branch = "main", + pr = null, + prNumber = null, + status = SessionRowVM.Status.Idle, + timeLabel = "5m", + unseen = false, + pinned = false, + sendFailed = false, + ) + + private val ui = SideChatsUi( + running = listOf(running), + chats = listOf(chat("engine:v1:k:side", "Veil under Reduce Motion")), + finished = listOf(failed, done), + ) + + private fun mountList(ui: SideChatsUi) { + compose.setContent { + RobocoTheme(Appearance.Light) { + Box(Modifier.fillMaxSize()) { + SideChatsList( + ui = ui, + bottomInset = 0f, + onOpenChat = { openedChats += it }, + onOpenAgent = { openedAgents += it }, + ) + } + } + } + compose.waitForIdle() + } + + // ------------------------------------------------------------ sections + + @Test + fun theThreeSectionsListTheirRowsUnderCountedHeaders() { + mountList(ui) + compose.onNodeWithTag("section-side-running").assertExists() + compose.onNodeWithTag("section-side-chats").assertExists() + compose.onNodeWithTag("section-side-finished").assertExists() + assertEquals( + listOf("Running, 1"), + compose.onNodeWithTag("section-side-running").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + assertEquals( + listOf("Finished agents, 2"), + compose.onNodeWithTag("section-side-finished").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("subagent-${running.docId}").assertExists() + compose.onNodeWithTag("subagent-${done.docId}").assertExists() + compose.onNodeWithTag("subagent-${failed.docId}").assertExists() + compose.onNodeWithTag("session-engine:v1:k:side").assertExists() + } + + @Test + fun anEmptySectionIsLeftOut() { + mountList(SideChatsUi(chats = listOf(chat("engine:v1:k:side", "Only a side chat")))) + compose.onNodeWithTag("section-side-chats").assertExists() + compose.onNodeWithTag("section-side-running").assertDoesNotExist() + compose.onNodeWithTag("section-side-finished").assertDoesNotExist() + } + + @Test + fun aHeaderTapFoldsItsSectionAndKeepsTheCount() { + mountList(ui) + compose.onNodeWithTag("section-side-finished").performClick() + compose.waitForIdle() + assertTrue(SideChatsFolds.isFolded(SideChatsSection.Finished)) + compose.onNodeWithTag("subagent-${done.docId}").assertDoesNotExist() + compose.onNodeWithTag("subagent-${failed.docId}").assertDoesNotExist() + compose.onNodeWithTag("subagent-${running.docId}").assertExists() + assertEquals( + "the count is over every row, folded or not", + listOf("Finished agents, 2"), + compose.onNodeWithTag("section-side-finished").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("section-side-finished").performClick() + compose.waitForIdle() + assertFalse(SideChatsFolds.isFolded(SideChatsSection.Finished)) + compose.onNodeWithTag("subagent-${done.docId}").assertExists() + } + + // ------------------------------------------------------------ rows + + @Test + fun rowTapsOpenTheSubagentOrTheSideChat() { + mountList(ui) + compose.onNodeWithTag("subagent-${running.docId}").performClick() + compose.onNodeWithTag("session-engine:v1:k:side").performClick() + compose.waitForIdle() + assertEquals(listOf(running), openedAgents) + assertEquals(listOf("engine:v1:k:side"), openedChats) + } + + @Test + fun aSubagentRowSpeaksItsStateModelAndTail() { + assertEquals( + "Subagent, Audit the reduced-motion fallback, Running, Haiku", + subagentRowA11yLabel(running), + ) + assertEquals( + "Subagent, Audit, Running, Reading RowView.swift", + subagentRowA11yLabel(SideChatsAgent("d", "Audit", AgentState.Running, tail = "Reading RowView.swift")), + ) + assertEquals("Subagent, Run the veil test matrix, Failed", subagentRowA11yLabel(failed)) + mountList(ui) + assertEquals( + listOf("Subagent, Check CJK line breaking, Done"), + compose.onNodeWithTag("subagent-${done.docId}").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + } + + @Test + fun finishedAgentsShowTheirAgeLikeTheSessionList() { + val now = 10_000_000L + assertEquals("now", relativeAgentTime(now - 59_000L, now)) + assertEquals("34m", relativeAgentTime(now - 34 * 60_000L, now)) + assertEquals("2h", relativeAgentTime(now - 2 * 3_600_000L - 5_000L, now)) + assertEquals("1d", relativeAgentTime(now - 86_400_000L, now - 0L)) + assertEquals("a future spawn reads now", "now", relativeAgentTime(now + 5_000L, now)) + assertEquals("Haiku", capitalizedModel("haiku")) + assertEquals("", capitalizedModel("")) + } + + // ------------------------------------------------------------ the + menu + + @Test + fun thePlusMenuOffersNewAndForkInOrder() { + var news = 0 + var forks = 0 + val menu = sideChatsAddMenu(onNew = { news++ }, onFork = { forks++ }) + assertNull(menu.title) + val items = menu.children.map { it as MenuItem } + assertEquals(listOf("New Side Chat", "Fork into Side Chat"), items.map { it.title }) + assertEquals(RobocoIcons.ArrowTriangleBranch, (items[1].icon as MenuIcon.Symbol).icon) + items[0].action() + items[1].action() + assertEquals(1, news) + assertEquals(1, forks) + } + + @Test + fun theOverlayHidesThePlusOnAnEngineWithoutSideChats() { + var canAdd by mutableStateOf(false) + val controller = SideChatsSheetController() + controller.open() + compose.setContent { + RobocoTheme(Appearance.Light) { + GlassMenuHost { + Box(Modifier.fillMaxSize()) { + SideChatsOverlay( + controller = controller, + ui = ui, + canAdd = canAdd, + onOpenChat = {}, + onOpenAgent = {}, + onNew = {}, + onFork = {}, + ) + } + } + } + } + compose.waitForIdle() + compose.onNodeWithText(SideChatsCopy.Title).assertExists() + compose.onNodeWithTag("side-chats-add").assertDoesNotExist() + compose.onNodeWithTag("sheet-done").assertExists() + + compose.runOnUiThread { canAdd = true } + compose.waitForIdle() + compose.onNodeWithTag("side-chats-add").assertExists() + } + + @Test + fun doneDismissesTheSheetThenRunsNothing() { + val controller = SideChatsSheetController() + controller.open() + var news = 0 + compose.setContent { + RobocoTheme(Appearance.Light) { + GlassMenuHost { + Box(Modifier.fillMaxSize()) { + SideChatsOverlay( + controller = controller, + ui = ui, + canAdd = true, + onOpenChat = {}, + onOpenAgent = {}, + onNew = { news++ }, + onFork = {}, + ) + } + } + } + } + compose.waitForIdle() + compose.onNodeWithTag("sheet-done").performClick() + compose.waitForIdle() + assertFalse("the sheet left after its exit animation", controller.showing) + assertEquals(0, news) + compose.onNodeWithTag("side-chats-sheet").assertDoesNotExist() + } + + @Test + fun theControllerRunsTheWaitingActionOnceDismissed() { + val controller = SideChatsSheetController() + controller.open() + var forks = 0 + controller.dismissThen { forks++ } + assertEquals("nothing before the sheet is gone", 0, forks) + controller.onDismissed() + assertFalse(controller.showing) + assertEquals(1, forks) + controller.onDismissed() + assertEquals("the action runs once", 1, forks) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt b/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt new file mode 100644 index 0000000000..0b4a886670 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt @@ -0,0 +1,92 @@ +package sh.roboco.android.transcript.widgets + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.transcript.TranscriptActions +import sh.roboco.android.transcript.isControl +import sh.roboco.android.transcript.isTranscriptControl +import uniffi.roboco_core.WidgetKind + +/** + * The subagent spawn card's tap target (android-next #06, upstream #899's + * `subagentLink` — a `ToolToggleControl` over the whole card): it opens + * the subagent once per tap, says "Open subagent: <title>" as a button, + * claims its down from the composer-dismiss tap like every transcript + * control, and keeps the row menu away (a control kind); the default + * actions leave a card inert. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w360dp-h300dp-mdpi") +class SubagentLinkTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + private val link = WidgetKind.SubagentLink( + docId = "engine:v1:k:chat--sub--toolu_1", + title = "Audit the reduced-motion fallback", + frozen = false, + ) + + @Test + fun aTapOpensTheSubagentOnceAndSpeaksAsAButton() { + val opened = mutableListOf<String>() + compose.setContent { + RobocoTheme(Appearance.Light) { + Box(Modifier.fillMaxSize()) { + // The Rust rect of a 30 pt card in its 38 pt row (T = 1.125). + Box(Modifier.absoluteOffset(18.dp, 4.dp).size(324.dp, 33.75.dp)) { + SubagentLinkTarget(title = link.title, onOpen = { opened += link.docId }) + } + } + } + } + compose.waitForIdle() + val node = compose.onNodeWithTag("subagent-link") + val config = node.fetchSemanticsNode().config + assertEquals(listOf("Open subagent: Audit the reduced-motion fallback"), config[SemanticsProperties.ContentDescription]) + assertEquals(Role.Button, config[SemanticsProperties.Role]) + node.performClick() + compose.waitForIdle() + assertEquals(listOf(link.docId), opened) + } + + @Test + fun theLinkIsATranscriptControl() { + assertTrue("its down never dismisses the composer", link.isTranscriptControl) + assertTrue("a long press over it opens no row menu", link.isControl()) + assertFalse(WidgetKind.Spinner.isTranscriptControl) + } + + @Test + fun theDefaultActionsLeaveTheCardInert() { + // No crash, no effect: tests and previews build actions without it. + TranscriptActions().openSubagent(link.docId, link.title, link.frozen) + var seen: Triple<String, String, Boolean>? = null + TranscriptActions(openSubagent = { d, t, f -> seen = Triple(d, t, f) }) + .openSubagent(link.docId, link.title, true) + assertEquals(Triple(link.docId, link.title, true), seen) + } +} From aa8f0d6b50cafeda68349867034b936c1b8bfacd Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Thu, 15 Oct 2026 21:37:50 +0700 Subject: [PATCH 10/13] fix(android): ticket 06 static compile pass --- .../src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt index 541ae376fe..310d141ab6 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt @@ -133,7 +133,7 @@ class CoreSideChatsSource( private val client: () -> CoreClient?, private val handle: SessionHandle, ) : SideChatsSource { - override fun agents(): List<SideChatsAgent> = handle.subagents().map(SideChatsAgent::from) + override fun agents(): List<SideChatsAgent> = handle.subagents().map { SideChatsAgent.from(it) } override fun children(parentChatId: String): List<SessionRowVM> = client()?.childSessions(parentChatId).orEmpty() From f06be6b7ab7e4dce0db988fd1057c34166ba79fa Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 00:15:02 +0700 Subject: [PATCH 11/13] fix(mobile-client): close the background-subagent test's outer any() --- crates/mobile-client/tests/side_chats_engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index 777a115d81..ec5ea63439 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -857,7 +857,7 @@ async fn a_background_subagent_binds_after_its_tool_result_and_freezes_on_done() .any(|entry| entry .parts .iter() - .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("parent turn done"))); + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("parent turn done")))); if answered { break; } From 1ffe097a05169b4b85f284d231eb20f94b47dad5 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 00:58:55 +0700 Subject: [PATCH 12/13] fix(mobile): SubagentInfo FFI test clones the row it reuses for the failed case --- crates/mobile/src/client_ffi/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mobile/src/client_ffi/session.rs b/crates/mobile/src/client_ffi/session.rs index 0124f8efa5..5288c01345 100644 --- a/crates/mobile/src/client_ffi/session.rs +++ b/crates/mobile/src/client_ffi/session.rs @@ -1103,7 +1103,7 @@ mod tests { model: Some("haiku".into()), spawned_at_ms: 1_000, }; - let info: SubagentInfo = row.into(); + let info: SubagentInfo = row.clone().into(); assert_eq!(info.doc_id, "engine:v1:chat--sub--s1"); assert_eq!(info.title, "Audit grapheme handling"); assert_eq!(info.state, SubagentState::Running); From 2a95044d0198f778b1968d2722f7ee552add9cbf Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 01:39:00 +0700 Subject: [PATCH 13/13] test(mobile-client): side-chat rig keeps its EngineListener alive serve()'s listener was dropped when rig_with_background returned, so the engine stopped listening and every first call failed with "Engine connection lost" (all five side_chats_engine tests on 1ffe097a). --- crates/mobile-client/tests/side_chats_engine.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index ec5ea63439..a3bc8aba8a 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -204,6 +204,10 @@ struct Rig { engine_dir: tempfile::TempDir, #[allow(dead_code)] data_dir: tempfile::TempDir, + /// Held for the rig's life: dropping it stops the engine listening, + /// and every call then fails with "Engine connection lost". + #[allow(dead_code)] + listener: roboco_engine::EngineListener, requests: Arc<Mutex<Vec<RunRequest>>>, } @@ -300,6 +304,7 @@ async fn rig_with_background( engine_id: record.engine_id, engine_dir, data_dir, + listener, requests, }; (rig, background_tx)