From 6d701c3dde904f0954a62b9e3b660715395f591a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 22:12:45 +0800 Subject: [PATCH 1/6] fix: stabilize history turns and device session navigation --- docs/architecture/peer-device-mode.md | 22 + .../services/services-integrations/AGENTS.md | 5 + .../remote_connect/host_stream_subscriber.rs | 280 +++++++++- .../src/remote_connect/session_records.rs | 10 + .../tests/remote_connect_contracts.rs | 8 + .../AgentCompanionDesktopPet.scss | 45 +- .../AgentCompanionDesktopPetStyles.test.ts | 27 +- .../src/app/components/NavPanel/NavPanel.scss | 32 +- .../components/DeviceStatusControl.test.tsx | 38 ++ .../components/DeviceStatusControl.tsx | 8 +- .../devices/BackgroundSessionList.tsx | 3 - .../sections/sessions/SessionsSection.tsx | 4 +- .../sessions/useSessionNavExpansion.test.tsx | 52 +- .../sessions/useSessionNavExpansion.ts | 92 ++-- .../workspaces/workspaceDisclosure.ts | 4 +- .../NavPanel/workspaceSessionView.ts | 6 +- .../btw/BtwVirtualSessionList.test.tsx | 4 + .../src/flow_chat/components/modern/AGENTS.md | 45 +- .../modern/FLOWCHAT_HISTORY_PAGING.md | 494 ++++-------------- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 175 +++---- .../modern/FLOWCHAT_VERIFICATION.md | 12 +- .../modern/FLOWCHAT_VIEWPORT_REGISTER.md | 18 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 120 ++--- .../modern/FlowChatPrependSnapshot.test.tsx | 20 +- .../modern/FlowChatPrependSnapshot.tsx | 29 +- .../modern/FlowChatScrollOriginBoundary.tsx | 37 ++ ...irtualMessageList.history-prepend.test.tsx | 216 ++++++++ .../modern/VirtualMessageList.resize.test.tsx | 221 ++++++++ .../components/modern/VirtualMessageList.scss | 47 ++ ...rtualMessageList.session-boundary.test.tsx | 23 +- ...rtualMessageList.timeline-prepend.test.tsx | 396 ++++++++++++++ .../components/modern/VirtualMessageList.tsx | 383 +++++++++----- .../modern/flowChatScrollCoordinates.ts | 22 + .../modern/flowChatViewportGeometry.test.ts | 20 + .../modern/flowChatViewportGeometry.ts | 16 +- ...eFlowChatFollowOutput.measurement.test.tsx | 4 + .../modern/useFlowChatFollowOutput.test.tsx | 68 ++- .../modern/useFlowChatFollowOutput.ts | 91 +++- .../modern/useFlowChatLeadingExtent.test.tsx | 19 +- .../modern/useFlowChatLeadingExtent.ts | 47 +- .../modern/useFlowChatViewportOwner.test.tsx | 35 ++ .../modern/useFlowChatViewportOwner.ts | 5 +- .../useFlowChatVirtualizer.aim.test.tsx | 5 + .../useFlowChatVirtualizer.cascade.test.tsx | 4 + .../useFlowChatVirtualizer.follow.test.tsx | 127 +++++ ...lowChatVirtualizer.initial-window.test.tsx | 4 + ...seFlowChatVirtualizer.measurement.test.tsx | 34 +- .../modern/useFlowChatVirtualizer.ts | 270 +++++++--- .../services/RelaySessionHistory.test.ts | 34 +- .../flow_chat/services/RelaySessionHistory.ts | 54 +- .../services/backgroundSurfaceSeeding.test.ts | 11 + .../services/backgroundSurfaceSeeding.ts | 11 +- .../src/flow_chat/store/FlowChatStore.test.ts | 34 +- .../src/flow_chat/store/FlowChatStore.ts | 11 +- 54 files changed, 2769 insertions(+), 1033 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatScrollOriginBoundary.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualMessageList.history-prepend.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualMessageList.resize.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualMessageList.timeline-prepend.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatScrollCoordinates.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatViewportGeometry.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.follow.test.tsx diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index a4106b4fd0..2e0985c3c6 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -125,6 +125,18 @@ records and responses from a device that is no longer rendered. Desktop `RelaySessionHistory` owns the subscription across initial loading, realtime delivery and older-page prefetch. +Desktop/Web FlowChat history publishes complete Turns. New host Turn roots +carry optional `turnRecordIds` membership on the record envelope. The Rust +subscriber joins byte/event-bounded pages until all listed members arrive; +a terminal root alone is not proof, since live updates can move it ahead of +unchanged items. Legacy hosts without membership remain readable: the controller +finishes the available history read before publication, which can make their +first open slower. Missing members, non-advancing pages and epoch changes fail +without exposing a partial Turn. No persisted shape or command changed. +`RelaySessionHistory` stages the resulting replay outside the store replica until +ready; timers and direct live-control flushes cannot publish its intermediate +records. Normal live updates retain their incremental behavior. + Native mobile history keeps these record-page boundaries; a page is not a complete conversation turn. The initial replay and each older-history request reduce all received records before publishing one transcript projection. A turn @@ -251,6 +263,16 @@ the same before, during and after a switch. Seeds carry no history or state machine; they are promoted through the normal metadata path before any open, and a seed that has been opened is never replaced by a later seed. +The displayed row count belongs to the surface/workspace/source view and is +published when the user expands or collapses it. Live and background lists +read the same snapshot during replacement; an unmount-time handoff is too late +for React's incoming render. Collapsing a workspace resets its source views; +Collapse All resets every view. Counts are window-local presentation state. +Disconnecting a control attachment disposes transcripts but leaves the +independent catalog read lease intact. Disposal invalidates the projection's +array deduplication and immediately reseeds metadata from that catalog, so +the still-visible sidebar does not empty while waiting for another read. + A cross-device open publishes a `DeviceOpenIntent` for its whole duration. While it is set, every device keeps its background rows (the rendered device hands over to its live list once, after the open, so the live list's reads never diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 1ce4f32bb0..a1b9503a47 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -45,6 +45,11 @@ slices that are outside pure product logic but still platform-neutral. `limit` is a budget for history reads: a page grows to the first record of its oldest turn so no turn splits across pages, within the hard event and byte bounds. Controllers must not assume a page holds at most `limit` events. + Root records carry optional `turnRecordIds` membership. The Rust subscriber + joins bounded history pages until those members are present, even if a live + root moved ahead of its unchanged children; legacy hosts without membership + finish their available history read before presentation. Never treat a page + limit, a terminal header or a timeout as proof of a complete Turn. - The `remote-persistence` feature is the lightweight persisted-shape owner shared by Remote Connect, remote SSH, and offline migration. Keep it free of network, SSH transport, and runtime orchestration dependencies so owner readers and diff --git a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs index 9a704f3e3e..c54181d994 100644 --- a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs +++ b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs @@ -7,16 +7,19 @@ use super::{ }; use anyhow::{anyhow, bail, Context, Result}; use serde::Deserialize; -use std::{sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; use tokio::sync::{broadcast, mpsc, oneshot}; const KEEPALIVE: Duration = Duration::from_secs(4 * 60); /// Event budget of the first page of an open: enough to fill a transcript view, /// so the session shows after one small read instead of a full 200-event / /// 768 KiB page over the relay. Older pages follow in the background. Every -/// host honours `limit`, so no capability is needed; current hosts extend the -/// page to the first record of the turn the budget ran out in, older ones cut it -/// mid-turn and their boundary turn fills in with the next page. +/// host honours `limit`. Transport pages are joined until their Turn membership +/// is complete; no transport budget is a presentation boundary. const FIRST_PAGE_EVENTS: usize = 48; const UNSUPPORTED_HOST_MESSAGE: &str = "The controlled device runs an older OpenBitFun version that does not support on-demand session streaming. Update OpenBitFun on that device to continue."; @@ -130,6 +133,101 @@ impl Reader { Ok(page) } + /// A root's optional membership list proves which records form a whole + /// Turn, even when live updates moved that root ahead of unchanged items. + /// Old hosts have no such proof: finish their bounded pages before exposing + /// history, rather than guessing from a header, count, or elapsed time. + async fn read_history(&self, request: StreamReadRequest) -> Result { + let mut page = self.read(request).await?; + let mut record_ids = HashSet::::new(); + let mut turns = HashMap::)>>::new(); + let mut batches = Vec::new(); + loop { + for event in &page.events { + if event.event != "session-record" { + continue; + } + let payload = &event.payload; + let Some(id) = payload["id"].as_str() else { + continue; + }; + record_ids.insert(id.to_owned()); + let Some(turn_id) = payload.pointer("/turn/turnId").and_then(|id| id.as_str()) + else { + continue; + }; + let membership = turns.entry(turn_id.to_owned()).or_default(); + if id == format!("turn/{turn_id}") { + if let Some(ids) = payload.get("turnRecordIds") { + let ids: Vec = serde_json::from_value(ids.clone()) + .context("Invalid history Turn membership")?; + if !ids.iter().any(|key| key == id) { + bail!("History Turn membership omitted its root"); + } + let revision = payload["revision"].as_u64().unwrap_or(event.seq); + if membership.as_ref().is_none_or(|(old, _)| revision > *old) { + *membership = Some((revision, ids)); + } + } + } + } + let complete = !turns.is_empty() + && turns.values().all(|membership| { + membership + .as_ref() + .is_some_and(|(_, ids)| ids.iter().all(|id| record_ids.contains(id))) + }); + let missing_record = turns + .values() + .flatten() + .any(|(_, ids)| ids.iter().any(|id| !record_ids.contains(id))); + if complete || !page.has_more { + if !complete && (page.truncated || missing_record) { + bail!("The host could not provide a complete history Turn; reopen the session to retry"); + } + // Reads travel backwards; delivery stays chronological. This + // also preserves ordering of runtime controls between records. + for batch in batches.into_iter().rev() { + page.events.extend(batch); + } + return Ok(page); + } + let before = page + .events + .first() + .map(|event| event.seq) + .context("History page did not advance")?; + let older = self + .read(StreamReadRequest { + stream_id: self.session_id.clone(), + before: Some(before), + epoch: Some(page.epoch), + subscribe: true, + ..Default::default() + }) + .await?; + if older.epoch != page.epoch { + bail!( + "Session history restarted while reading a Turn; reopen the session to retry" + ); + } + if older + .events + .first() + .is_some_and(|event| event.seq >= before) + || (older.events.is_empty() && older.has_more) + { + bail!("History page did not advance"); + } + batches.push(page.events); + // Forward catch-up starts at the snapshot with which this read + // began. Later appends must still be delivered by catch_up. + let cursor = page.cursor; + page = older; + page.cursor = cursor; + } + } + fn emit_page(&self, page: &StreamPage) -> Result<()> { for event in &page.events { (self.emit)(SessionEvent { @@ -152,7 +250,7 @@ impl Reader { /// Latest page first, like opening a chat at its bottom. async fn resync(&mut self) -> Result<()> { let page = self - .read(StreamReadRequest { + .read_history(StreamReadRequest { stream_id: self.session_id.clone(), subscribe: true, limit: Some(FIRST_PAGE_EVENTS), @@ -214,7 +312,7 @@ impl Reader { return Ok(true); } let page = self - .read(StreamReadRequest { + .read_history(StreamReadRequest { stream_id: self.session_id.clone(), before: Some(self.oldest), epoch: Some(self.epoch), @@ -502,7 +600,175 @@ mod tests { } fn record(session: &str, turn: &str, status: &str) -> serde_json::Value { - serde_json::json!({"sessionId":session,"id":format!("turn/{turn}"),"turn":{"turnId":turn,"sessionId":session,"status":status}}) + serde_json::json!({"sessionId":session,"id":format!("turn/{turn}"),"turnRecordIds":[format!("turn/{turn}")],"turn":{"turnId":turn,"sessionId":session,"status":status}}) + } + + struct ScriptedHistory { + pages: Mutex>, + reads: Mutex>, + } + #[async_trait::async_trait] + impl StreamTransport for ScriptedHistory { + async fn read_stream(&self, request: StreamReadRequest) -> Result { + self.reads.lock().unwrap().push(request); + self.pages + .lock() + .unwrap() + .pop_front() + .context("Unexpected history read") + } + async fn unsubscribe_stream(&self, _: &str) -> Result<()> { + Ok(()) + } + } + fn history_page(seq: u64, records: Vec, has_more: bool) -> StreamPage { + use super::super::host_stream::StreamEvent; + StreamPage { + stream_id: "s".into(), + epoch: 1, + events: records + .into_iter() + .enumerate() + .map(|(index, mut payload)| { + payload["revision"] = (seq + index as u64).into(); + StreamEvent { + seq: seq + index as u64, + event: "session-record".into(), + payload, + } + }) + .collect(), + has_more, + cursor: 100, + oldest_seq: 1, + truncated: false, + } + } + fn scripted_reader( + pages: Vec, + ) -> (Reader, Arc, Arc>>) { + let transport = Arc::new(ScriptedHistory { + pages: Mutex::new(pages.into()), + reads: Mutex::new(Vec::new()), + }); + let events = Arc::new(Mutex::new(Vec::new())); + let output = events.clone(); + ( + Reader { + transport: transport.clone(), + session_id: "s".into(), + emit: Arc::new(move |event| { + output.lock().unwrap().push(event); + Ok(()) + }), + epoch: 0, + cursor: 0, + oldest: 1, + has_more: false, + }, + transport, + events, + ) + } + fn item(id: &str) -> serde_json::Value { + serde_json::json!({"sessionId":"s","id":format!("item/{id}"),"turn":{"turnId":"one","sessionId":"s","status":"completed"},"round":{"id":"r","turnId":"one"},"item":{"type":"text","data":{"id":id}}}) + } + + #[tokio::test] + async fn history_waits_for_all_members_even_when_the_terminal_root_arrived_first() { + let mut root = record("s", "one", "completed"); + root["turnRecordIds"] = serde_json::json!(["turn/one", "item/a", "item/b", "item/c"]); + let (mut reader, transport, events) = scripted_reader(vec![ + history_page(8, vec![root, item("c")], true), + history_page(5, vec![item("b")], true), + history_page(2, vec![item("a")], true), + ]); + reader.resync().await.unwrap(); + let reads = transport.reads.lock().unwrap(); + assert_eq!( + reads.iter().map(|read| read.before).collect::>(), + vec![None, Some(8), Some(5)] + ); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 5); + assert_eq!(events[0].payload["id"], "item/a"); + assert_eq!(events[3].payload["id"], "item/c"); + assert_eq!(events[4].event, "relay://session-ready"); + assert_eq!(events[4].payload["hasMore"], true); + assert_eq!(events[4].payload["oldestSeq"], 2); + assert_eq!(events[4].payload["cursor"], 100); + } + + #[tokio::test] + async fn legacy_pages_without_membership_remain_readable_and_publish_once_complete() { + let mut root = record("s", "one", "completed"); + root.as_object_mut().unwrap().remove("turnRecordIds"); + // Round trip the old wire shape, without inventing a new capability. + let first: StreamPage = serde_json::from_value( + serde_json::to_value(history_page(8, vec![root], true)).unwrap(), + ) + .unwrap(); + let (mut reader, transport, events) = scripted_reader(vec![ + first, + history_page(5, vec![item("b")], true), + history_page(1, vec![item("a")], false), + ]); + reader.resync().await.unwrap(); + assert_eq!(transport.reads.lock().unwrap().len(), 3); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 4); + assert_eq!(events.last().unwrap().payload["hasMore"], false); + assert_eq!(events[0].payload["item"]["data"]["id"], "a"); + } + + #[tokio::test] + async fn missing_members_and_failed_continuations_never_emit_partial_turns() { + for has_more in [false, true] { + let mut root = record("s", "one", "completed"); + root["turnRecordIds"] = serde_json::json!(["turn/one", "item/missing"]); + let (mut reader, _, events) = + scripted_reader(vec![history_page(8, vec![root], has_more)]); + assert!(reader.resync().await.is_err()); + assert!(events.lock().unwrap().is_empty()); + } + } + + #[tokio::test] + async fn continuation_epoch_changes_and_stalled_cursors_never_publish_fragments() { + for kind in ["epoch", "empty", "same-cursor"] { + let mut root = record("s", "one", "completed"); + root["turnRecordIds"] = serde_json::json!(["turn/one", "item/a"]); + let mut older = history_page( + if kind == "same-cursor" { 8 } else { 2 }, + vec![item("a")], + true, + ); + if kind == "epoch" { + older.epoch = 2; + } + if kind == "empty" { + older.events.clear(); + } + let (mut reader, _, events) = + scripted_reader(vec![history_page(8, vec![root], true), older]); + assert!(reader.resync().await.is_err(), "{kind}"); + assert!(events.lock().unwrap().is_empty(), "{kind}"); + } + } + + #[tokio::test] + async fn a_control_only_page_does_not_complete_an_empty_transcript() { + let mut controls = history_page(8, vec![serde_json::json!({"sessionId":"s"})], true); + controls.events[0].event = "session-interaction-changed".into(); + let (mut reader, transport, events) = scripted_reader(vec![ + controls, + history_page(2, vec![record("s", "one", "completed")], true), + ]); + reader.resync().await.unwrap(); + assert_eq!(transport.reads.lock().unwrap().len(), 2); + let events = events.lock().unwrap(); + assert_eq!(events[0].payload["id"], "turn/one"); + assert_eq!(events[2].event, "relay://session-ready"); } #[tokio::test] diff --git a/src/crates/services/services-integrations/src/remote_connect/session_records.rs b/src/crates/services/services-integrations/src/remote_connect/session_records.rs index a9aa177067..6d9112be03 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_records.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_records.rs @@ -64,6 +64,7 @@ pub fn records_from_turns( ) -> Result> { let mut records = Vec::new(); for source in turns { + let turn_start = records.len(); let mut turn = serde_json::to_value(source)?; turn.as_object_mut() .expect("turn serializes as object") @@ -95,6 +96,15 @@ pub fn records_from_turns( } } } + // A transport page is byte/event bounded and can split even one Turn. + // Put membership on its root only, so a controller can prove it has the + // whole snapshot without duplicating this list on every item. This is + // an optional wire field; persisted Turns and older readers are unchanged. + let record_ids: Vec = records[turn_start..] + .iter() + .map(|record| record["id"].clone()) + .collect(); + records[turn_start]["turnRecordIds"] = Value::Array(record_ids); } Ok(records) } diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 52ba3d88f0..7927f8848a 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -476,6 +476,14 @@ fn relay_records_carry_attachment_pixels_on_the_turn_record_alone() { .iter() .find(|record| record["id"] == "turn/turn-1") .expect("turn record is published"); + assert_eq!( + turn_record["turnRecordIds"], + serde_json::json!(["turn/turn-1", "round/round-1", "item/item-1"]) + ); + assert!(records + .iter() + .skip(1) + .all(|record| record.get("turnRecordIds").is_none())); assert_eq!( images_of(turn_record), vec![ diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index 3618010a71..717143b9e0 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -364,30 +364,18 @@ width: 180px; max-width: 100%; box-sizing: border-box; - /* Same language as the device overview: the opaque themed surface lit - from the corner nearest the title by the task state, a faint ring in - that light, and the tail corner pointing at the pet. */ + /* Both themes use an 80% opaque neutral surface. Task color belongs to the + status glyph; a neutral inset edge keeps the bubble clear on any desktop. */ --_bubble-light: var(--openbitfun-color-accent-default); - --_bubble-glow: radial-gradient( - 130% 150% at 0% 0%, - color-mix(in srgb, var(--_bubble-light) 20%, transparent), - transparent 68% - ); - /* No border: the bubble is defined by its surface, the state light, the - theme's top highlight and a soft tinted drop shadow cast down and to - the right at 45 degrees. Its reach stays inside the column: none to the - left, and the column's inline-end room covers the right. */ --_bubble-edge: - var(--openbitfun-shadow-inner-highlight), - 10px 10px 24px -10px color-mix(in srgb, var(--_bubble-light) 40%, transparent); + inset 0 0 0 var(--openbitfun-border-width-default) var(--openbitfun-color-border-subtle), + var(--openbitfun-shadow-raised); padding: 9px 12px; border: none; border-radius: 14px 14px 4px 14px; - background-color: var(--openbitfun-color-surface-raised); - background-image: var(--_bubble-glow); + background-color: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent); color: var(--openbitfun-color-content-primary); box-shadow: var(--_bubble-edge); - backdrop-filter: blur(10px); cursor: pointer; position: relative; z-index: 0; @@ -518,15 +506,9 @@ } } - /* Each state sets the light and lays its status tint over the opaque themed - surface, fading away from the lit corner so the bubble stays calm. */ + /* All task states share the translucent surface; glyphs carry the status color. */ &__bubble--attention { --_bubble-light: var(--openbitfun-color-status-warning-content); - background-image: linear-gradient( - 135deg, - var(--openbitfun-color-status-warning-surface), - transparent 72% - ), var(--_bubble-glow); box-shadow: var(--_bubble-edge), inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 40%, transparent); @@ -535,30 +517,15 @@ &__bubble--waiting { --_bubble-light: var(--openbitfun-color-status-warning-content); - background-image: linear-gradient( - 135deg, - var(--openbitfun-color-status-warning-surface), - transparent 72% - ), var(--_bubble-glow); } &__bubble--completed { --_bubble-light: var(--openbitfun-color-status-success-content); - background-image: linear-gradient( - 135deg, - var(--openbitfun-color-status-success-surface), - transparent 72% - ), var(--_bubble-glow); } &__bubble--error, &__bubble--interrupted { --_bubble-light: var(--openbitfun-color-status-danger-content); - background-image: linear-gradient( - 135deg, - var(--openbitfun-color-status-danger-surface), - transparent 72% - ), var(--_bubble-glow); } .openbitfun-agent-companion-pet { diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts index 496d56400d..fed96d99fc 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts @@ -42,7 +42,7 @@ describe('AgentCompanionDesktopPet styles', () => { const output = extractBlock(stylesheet, '&__bubble-output'); const composerInput = extractBlock(stylesheet, '&__bubble-composer-input'); - expect(bubble).toContain('background-color: var(--openbitfun-color-surface-raised);'); + expect(bubble).toContain('background-color: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent);'); expect(bubble).toContain('color: var(--openbitfun-color-content-primary);'); // Status words are drawn as a glyph; the text stays for assistive tech only. expect(status).toContain('clip-path: inset(50%);'); @@ -62,19 +62,26 @@ describe('AgentCompanionDesktopPet styles', () => { ); }); - it('layers translucent task-state colors over the opaque themed bubble surface', () => { + it('keeps the themed bubble background 20% transparent without fading its content', () => { const stylesheet = readStylesheet(); + const bubble = extractBlock(stylesheet, '&__bubble'); + + expect(bubble).toContain('background-color: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent);'); + expect(bubble).not.toMatch(/^\s*opacity\s*:/m); + expect(bubble).not.toMatch(/gradient\(|background-image:|backdrop-filter:/); + expect(bubble).toContain('var(--openbitfun-color-border-subtle)'); + expect(bubble).toContain('var(--openbitfun-shadow-raised)'); + expect(stylesheet).not.toContain('--_bubble-glow'); - for (const [selector, surfaceToken] of [ - ['&__bubble--attention', '--openbitfun-color-status-warning-surface'], - ['&__bubble--waiting', '--openbitfun-color-status-warning-surface'], - ['&__bubble--completed', '--openbitfun-color-status-success-surface'], - ['&__bubble--error,\n &__bubble--interrupted', '--openbitfun-color-status-danger-surface'], + for (const [selector, contentToken] of [ + ['&__bubble--attention', '--openbitfun-color-status-warning-content'], + ['&__bubble--waiting', '--openbitfun-color-status-warning-content'], + ['&__bubble--completed', '--openbitfun-color-status-success-content'], + ['&__bubble--error,\n &__bubble--interrupted', '--openbitfun-color-status-danger-content'], ] as const) { const state = extractBlock(stylesheet, selector); - expect(state).toContain('background-image: linear-gradient('); - expect(state).toContain(`var(${surfaceToken})`); - expect(state).not.toContain(`background: var(${surfaceToken});`); + expect(state).toContain(`--_bubble-light: var(${contentToken});`); + expect(state).not.toMatch(/background(?:-color|-image)?:|gradient\(/); } }); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 03471b0891..1187c38a19 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1561,8 +1561,7 @@ $_section-header-height: 22px; // Device overview — one compact model, local and connected states // ────────────────────────────────────────────── -// The light that fills the card is the displayed device's state; registering it -// lets the whole surface cross-fade between devices instead of snapping. +// State color is limited to the device controls; the card stays neutral. @property --_device-overview-light { syntax: ''; inherits: true; @@ -1577,10 +1576,8 @@ $_section-header-height: 22px; --_device-overview-inline-size: min(312px, calc(100vw - 16px)); --_device-overview-stage-size: 120px; --_device-overview-inset: var(--openbitfun-space-4); - // Silver until a device is under control: this device and a device not yet - // connected share one neutral light; only a live connection turns green. + // Local and disconnected devices use neutral controls; a live connection is green. --_device-overview-light: var(--openbitfun-color-content-secondary); - --_device-overview-light-strength: 16%; position: fixed; z-index: var(--openbitfun-layer-popover); width: var(--_device-overview-inline-size); @@ -1589,25 +1586,10 @@ $_section-header-height: 22px; display: flex; flex-direction: column; box-sizing: border-box; - background: - radial-gradient( - 120% 78% at 50% 0%, - color-mix(in srgb, var(--_device-overview-light) var(--_device-overview-light-strength), transparent), - transparent 74% - ), - radial-gradient( - 90% 55% at 100% 100%, - color-mix(in srgb, var(--_device-overview-light) calc(var(--_device-overview-light-strength) / 3), transparent), - transparent 70% - ), - var(--openbitfun-color-surface-raised); + background: var(--openbitfun-color-surface-raised); transition: --_device-overview-light var(--openbitfun-motion-duration-slow) var(--openbitfun-motion-easing-standard); animation: openbitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; - :where([data-color-scheme='dark']) & { - --_device-overview-light-strength: 26%; - } - &[data-control-state='active'] { --_device-overview-light: var(--openbitfun-color-status-success-content); } @@ -1937,7 +1919,7 @@ $_section-header-height: 22px; } } -// A soft top-lit fill reads as a material rather than a flat cut-out. +// A single neutral fill keeps the device silhouette legible on the solid card. .openbitfun-device-overview__artwork-glyph { position: relative; justify-self: center; @@ -1945,11 +1927,7 @@ $_section-header-height: 22px; height: 56%; margin-block-end: var(--openbitfun-space-2); aspect-ratio: 140 / 78; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--openbitfun-color-content-primary) 72%, transparent), - color-mix(in srgb, var(--openbitfun-color-content-primary) 42%, transparent) - ); + background: var(--openbitfun-color-content-secondary); mask: url('./assets/device.svg') center / contain no-repeat; } diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index e1cde0d5aa..c3c700db41 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -208,6 +208,44 @@ afterEach(() => { }); describe('device status card', () => { + it.each(['signed-out', 'loading', 'authorizing'])( + 'opens device management directly when account identity is %s', status => { + state.identity = { status, me: null }; + render(false); + + act(() => element('nav-footer-device-status').click()); + + expect(onManageDevices).toHaveBeenCalledOnce(); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + expect(element('nav-footer-device-status').getAttribute('aria-expanded')).toBe('false'); + expect(document.querySelector('[data-testid="nav-device-status-popover"]')).toBeNull(); + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.accountListDevices).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])('toggles the overview when signed in (peer active: %s)', async peerActive => { + state.identity = { status: 'signed-in', me: { user: { accountId: 'acct' } } }; + state.overview = overview({ peer: peerActive ? { deviceId: 'peer-1', deviceName: 'Remote Mac' } : null }); + state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'Workstation' }); + state.accountListDevices.mockResolvedValue([]); + function Footer() { + const [open, setOpen] = React.useState(false); + return ; + } + act(() => root.render(